├── androidApp
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ └── values
│ │ │ │ └── colors.xml
│ │ ├── java
│ │ │ └── dev
│ │ │ │ └── johnoreilly
│ │ │ │ └── mortycomposekmm
│ │ │ │ ├── di
│ │ │ │ └── AppModule.kt
│ │ │ │ ├── MortyComposeKMMApplication.kt
│ │ │ │ └── ui
│ │ │ │ ├── locations
│ │ │ │ ├── LocationsListView.kt
│ │ │ │ ├── LocationsListRowView.kt
│ │ │ │ └── LocationDetailView.kt
│ │ │ │ ├── episodes
│ │ │ │ ├── EpisodesListView.kt
│ │ │ │ ├── EpisodesListRowView.kt
│ │ │ │ └── EpisodeDetailView.kt
│ │ │ │ ├── characters
│ │ │ │ ├── CharactersListView.kt
│ │ │ │ ├── CharactersListRowView.kt
│ │ │ │ └── CharacterDetailView.kt
│ │ │ │ ├── components
│ │ │ │ ├── MortyTopAppBar.kt
│ │ │ │ ├── LoadingAnimations.kt
│ │ │ │ └── ErrorScreens.kt
│ │ │ │ ├── theme
│ │ │ │ └── Theme.kt
│ │ │ │ └── MainActivity.kt
│ │ └── AndroidManifest.xml
│ └── androidTest
│ │ └── java
│ │ └── MortyConposeTest.kt
└── build.gradle.kts
├── shared
├── .gitignore
├── src
│ ├── androidMain
│ │ └── AndroidManifest.xml
│ ├── commonMain
│ │ ├── kotlin
│ │ │ └── dev
│ │ │ │ └── johnoreilly
│ │ │ │ └── mortycomposekmm
│ │ │ │ └── shared
│ │ │ │ ├── di
│ │ │ │ └── Koin.kt
│ │ │ │ ├── paging
│ │ │ │ ├── EpisodesDataSource.kt
│ │ │ │ ├── LocationsDataSource.kt
│ │ │ │ └── CharactersDataSource.kt
│ │ │ │ ├── viewmodel
│ │ │ │ ├── EpisodesViewModel.kt
│ │ │ │ ├── LocationsViewModel.kt
│ │ │ │ └── CharactersViewModel.kt
│ │ │ │ └── MortyRepository.kt
│ │ └── graphql
│ │ │ └── dev
│ │ │ └── johnoreilly
│ │ │ └── mortycomposekmm
│ │ │ ├── extra.graphqls
│ │ │ ├── Queries.graphql
│ │ │ └── schema.graphqls
│ └── iosTest
│ │ └── kotlin
│ │ └── dev
│ │ └── johnoreilly
│ │ └── mortycomposekmm
│ │ └── shared
│ │ └── iosTest.kt
└── build.gradle.kts
├── art
├── characters_screenshot.png
└── characters_screenshot_ios.png
├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── libs.versions.toml
├── iosApp
├── iosApp
│ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ └── AppIcon.appiconset
│ │ │ └── Contents.json
│ ├── Preview Content
│ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ ├── KMPViewModel.swift
│ ├── Features
│ │ ├── Locations
│ │ │ ├── LocationsListRowView.swift
│ │ │ └── LocationsListView.swift
│ │ ├── Episodes
│ │ │ ├── EpisodeListViewModel.swift
│ │ │ ├── EpisodesListRowView.swift
│ │ │ └── EpisodesListView.swift
│ │ └── Characters
│ │ │ ├── CharactersListView.swift
│ │ │ ├── CharactersListRowView.swift
│ │ │ └── CharacterDetailView.swift
│ ├── ContentView.swift
│ ├── AppDelegate.swift
│ ├── Base.lproj
│ │ └── LaunchScreen.storyboard
│ ├── Info.plist
│ └── SceneDelegate.swift
├── iosApp.xcodeproj
│ ├── project.xcworkspace
│ │ └── xcshareddata
│ │ │ ├── IDEWorkspaceChecks.plist
│ │ │ └── swiftpm
│ │ │ └── Package.resolved
│ └── project.pbxproj
├── iosAppTests
│ ├── Info.plist
│ └── iosAppTests.swift
└── iosAppUITests
│ ├── Info.plist
│ └── iosAppUITests.swift
├── renovate.json
├── .junie
└── guidelines.md
├── .github
└── workflows
│ └── android.yml
├── settings.gradle.kts
├── .gitignore
├── gradle.properties
├── README.md
├── gradlew.bat
├── gradlew
└── LICENSE
/androidApp/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 | *.iml
3 |
--------------------------------------------------------------------------------
/shared/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 | *.iml
3 |
4 |
--------------------------------------------------------------------------------
/art/characters_screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joreilly/MortyComposeKMM/HEAD/art/characters_screenshot.png
--------------------------------------------------------------------------------
/art/characters_screenshot_ios.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joreilly/MortyComposeKMM/HEAD/art/characters_screenshot_ios.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joreilly/MortyComposeKMM/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/iosApp/iosApp/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "version" : 1,
4 | "author" : "xcode"
5 | }
6 | }
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "config:base"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "version" : 1,
4 | "author" : "xcode"
5 | }
6 | }
--------------------------------------------------------------------------------
/shared/src/androidMain/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/iosApp/iosApp/KMPViewModel.swift:
--------------------------------------------------------------------------------
1 | import shared
2 | import KMPObservableViewModelCore
3 |
4 | extension Kmp_observableviewmodel_coreViewModel: ViewModel { }
5 |
--------------------------------------------------------------------------------
/androidApp/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #6200EE
4 | #3700B3
5 | #03DAC5
6 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.junie/guidelines.md:
--------------------------------------------------------------------------------
1 | # Project Guidelines
2 |
3 | ## Project Structure
4 | This is a Kotlin Multiplatform project with Compose Multiplatform that includes:
5 | * `composeApp` - Shared Kotlin code with Compose UI
6 | * `iosApp` - iOS application
7 |
8 | ## Building the Project
9 | When building this project, Junie should use the following Gradle task:
10 | ```
11 | :shared:compileDebugSources
12 | ```
13 |
--------------------------------------------------------------------------------
/.github/workflows/android.yml:
--------------------------------------------------------------------------------
1 | name: Android CI
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build:
7 |
8 | runs-on: macos-latest
9 |
10 | steps:
11 | - uses: actions/checkout@v4
12 | - name: set up JDK 17
13 | uses: actions/setup-java@v4
14 | with:
15 | distribution: 'zulu'
16 | java-version: 17
17 | - name: Build android app
18 | run: ./gradlew assembleDebug
19 | - name: Build iOS shared code
20 | run: ./gradlew :shared:compileKotlinIosArm64
21 |
22 |
--------------------------------------------------------------------------------
/androidApp/src/main/java/dev/johnoreilly/mortycomposekmm/di/AppModule.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.di
2 |
3 | import dev.johnoreilly.mortycomposekmm.shared.viewmodel.CharactersViewModel
4 | import dev.johnoreilly.mortycomposekmm.shared.viewmodel.EpisodesViewModel
5 | import dev.johnoreilly.mortycomposekmm.shared.viewmodel.LocationsViewModel
6 | import org.koin.androidx.viewmodel.dsl.viewModel
7 | import org.koin.dsl.module
8 |
9 | val appModule = module {
10 | viewModel { CharactersViewModel() }
11 | viewModel { EpisodesViewModel() }
12 | viewModel { LocationsViewModel() }
13 | }
14 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/dev/johnoreilly/mortycomposekmm/shared/di/Koin.kt:
--------------------------------------------------------------------------------
1 | package com.surrus.common.di
2 |
3 | import dev.johnoreilly.mortycomposekmm.shared.MortyRepository
4 | import org.koin.core.context.startKoin
5 | import org.koin.dsl.KoinAppDeclaration
6 | import org.koin.dsl.module
7 |
8 | fun initKoin(appDeclaration: KoinAppDeclaration = {}) =
9 | startKoin {
10 | appDeclaration()
11 | modules(commonModule())
12 | }
13 |
14 | // called by iOS etc
15 | fun initKoin() = initKoin() {}
16 |
17 | fun commonModule() = module {
18 | single { MortyRepository() }
19 | }
20 |
--------------------------------------------------------------------------------
/shared/src/iosTest/kotlin/dev/johnoreilly/mortycomposekmm/shared/iosTest.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.shared
2 |
3 | import kotlinx.coroutines.runBlocking
4 | import kotlin.test.Test
5 |
6 | class IosGreetingTest {
7 |
8 | @Test
9 | fun testExample() {
10 | // TODO this doesn't get passed getCharacters call right now....need to figure out why!
11 | runBlocking {
12 | val repository = MortyRepository()
13 |
14 | // val charactersResponse = repository.getCharacters(0)
15 | // println(charactersResponse?.results)
16 | }
17 | }
18 | }
--------------------------------------------------------------------------------
/iosApp/iosApp/Features/Locations/LocationsListRowView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import shared
3 |
4 | struct LocationsListRowView: View {
5 | let location: LocationDetail
6 |
7 | var body: some View {
8 | VStack(alignment: .leading) {
9 | Text(location.name)
10 | .font(.title3)
11 | .foregroundColor(.accentColor)
12 | .fontWeight(.bold)
13 | .lineLimit(1)
14 |
15 | Text("\(location.residents.count) resident(s)")
16 | .font(.footnote)
17 | .foregroundColor(.gray)
18 | }
19 | .padding(.vertical, 4)
20 | }
21 | }
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | listOf(repositories, dependencyResolutionManagement.repositories).forEach {
3 | it.apply {
4 | mavenCentral()
5 | google()
6 | gradlePluginPortal()
7 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
8 | maven("https://androidx.dev/storage/compose-compiler/repository")
9 | }
10 | }
11 | }
12 |
13 | rootProject.name = "MortyComposeKMM"
14 |
15 |
16 | include(":androidApp")
17 | include(":shared")
18 |
19 | check(JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_17)) {
20 | "This project needs to be run with Java 17 or higher (found: ${JavaVersion.current()})."
21 | }
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Ignore Gradle GUI config
3 | gradle-app.setting
4 |
5 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
6 | !gradle-wrapper.jar
7 |
8 | # Cache of project
9 | .gradletasknamecache
10 |
11 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898
12 | # gradle/wrapper/gradle-wrapper.properties
13 |
14 | .idea
15 | *.iml
16 | .gradle
17 | /local.properties
18 | /.idea/caches
19 | /.idea/libraries
20 | /.idea/modules.xml
21 | /.idea/workspace.xml
22 | /.idea/navEditor.xml
23 | /.idea/assetWizardSettings.xml
24 | .DS_Store
25 | /build
26 | /captures
27 |
28 |
29 | *.xcworkspacedata
30 | *.xcuserstate
31 | *.xcscheme
32 | xcschememanagement.plist
33 | *.xcbkptlist
34 |
35 | /.kotlin/
--------------------------------------------------------------------------------
/iosApp/iosAppTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/iosApp/iosAppUITests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/androidApp/src/main/java/dev/johnoreilly/mortycomposekmm/MortyComposeKMMApplication.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm
2 |
3 | import android.app.Application
4 | import com.surrus.common.di.initKoin
5 | import dev.johnoreilly.mortycomposekmm.di.appModule
6 | import org.koin.android.ext.koin.androidContext
7 | import org.koin.android.ext.koin.androidLogger
8 | import org.koin.core.logger.Level
9 |
10 | class MortyComposeKMMApplication : Application() {
11 |
12 | override fun onCreate() {
13 | super.onCreate()
14 |
15 | initKoin {
16 | // workaround for https://github.com/InsertKoinIO/koin/issues/1188
17 | androidLogger(if (BuildConfig.DEBUG) Level.ERROR else Level.NONE)
18 | androidContext(this@MortyComposeKMMApplication)
19 | modules(appModule)
20 | }
21 | }
22 |
23 | }
--------------------------------------------------------------------------------
/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "992c580d559a5a60ae0f84897b206d4a07663d4cd55d674d1586fc3d71b118f9",
3 | "pins" : [
4 | {
5 | "identity" : "kingfisher",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/onevcat/Kingfisher.git",
8 | "state" : {
9 | "revision" : "1a0c2df04b31ed7aa318354f3583faea24f006fc",
10 | "version" : "5.15.8"
11 | }
12 | },
13 | {
14 | "identity" : "kmp-observableviewmodel",
15 | "kind" : "remoteSourceControl",
16 | "location" : "https://github.com/rickclephas/KMP-ObservableViewModel.git",
17 | "state" : {
18 | "revision" : "6ab62549dae7ce60eb29882db33e9909a949619b",
19 | "version" : "1.0.0-BETA-2"
20 | }
21 | }
22 | ],
23 | "version" : 3
24 | }
25 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Features/Episodes/EpisodeListViewModel.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftUI
3 | import shared
4 | import KMPNativeCoroutinesAsync
5 |
6 | class EpisodeListViewModel: ObservableObject {
7 | @Published public var episodes: [EpisodeDetail] = []
8 | @State var repository = MortyRepository()
9 | var hasNextPage: Bool = false
10 |
11 | func fetchEpisodes() async {
12 | do {
13 | for try await episodes in asyncSequence(for: repository.episodesSnapshotList) {
14 | self.episodes = episodes as! [EpisodeDetail]
15 | }
16 | } catch {
17 | print("Failed with error: \(error)")
18 | }
19 | }
20 |
21 | func getElement(index: Int) -> EpisodeDetail? {
22 | return repository.episodesPagingDataPresenter.get(index: Int32(index))
23 | }
24 |
25 | }
26 |
27 |
--------------------------------------------------------------------------------
/androidApp/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/iosApp/iosAppTests/iosAppTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import iosApp
3 |
4 | class iosAppTests: XCTestCase {
5 |
6 | override func setUp() {
7 | // Put setup code here. This method is called before the invocation of each test method in the class.
8 | }
9 |
10 | override func tearDown() {
11 | // Put teardown code here. This method is called after the invocation of each test method in the class.
12 | }
13 |
14 | func testExample() {
15 | // This is an example of a functional test case.
16 | // Use XCTAssert and related functions to verify your tests produce the correct results.
17 | }
18 |
19 | func testPerformanceExample() {
20 | // This is an example of a performance test case.
21 | self.measure {
22 | // Put the code you want to measure the time of here.
23 | }
24 | }
25 |
26 | }
27 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | ## For more details on how to configure your build environment visit
2 | # http://www.gradle.org/docs/current/userguide/build_environment.html
3 | #
4 | # Specifies the JVM arguments used for the daemon process.
5 | # The setting is particularly useful for tweaking memory settings.
6 | # Default value: -Xmx1024m -XX:MaxPermSize=256m
7 | # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
8 | #
9 | # When configured, Gradle will run in incubating parallel mode.
10 | # This option should only be used with decoupled projects. More details, visit
11 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
12 | # org.gradle.parallel=true
13 | #Mon Dec 21 18:47:05 GMT 2020
14 | kotlin.code.style=official
15 | xcodeproj=./iosApp
16 | org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx1024M"
17 | android.useAndroidX=true
18 |
19 | kotlin.experimental.tryK2=true
20 |
21 |
--------------------------------------------------------------------------------
/iosApp/iosApp/ContentView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import shared
3 |
4 |
5 | struct ContentView: View {
6 | var body: some View {
7 | TabView {
8 | NavigationView {
9 | CharactersListView()
10 | }
11 | .tabItem {
12 | Label("Characters", systemImage: "person.crop.square.fill.and.at.rectangle")
13 | }
14 |
15 | NavigationView {
16 | EpisodesListView()
17 | }
18 | .tabItem {
19 | Label("Episodes", systemImage: "tv")
20 | }
21 |
22 | NavigationView {
23 | LocationsListView()
24 | }
25 | .tabItem {
26 | Label("Locations", systemImage: "location")
27 | }
28 | }
29 | }
30 | }
31 |
32 | struct ContentView_Previews: PreviewProvider {
33 | static var previews: some View {
34 | ContentView()
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/shared/src/commonMain/graphql/dev/johnoreilly/mortycomposekmm/extra.graphqls:
--------------------------------------------------------------------------------
1 | extend type Query @nonnull(fields: """
2 | character
3 | characters
4 | charactersByIds
5 | location
6 | locations
7 | locationsByIds
8 | episode
9 | episodes
10 | episodesByIds
11 | """)
12 |
13 | extend type Character @nonnull(fields: """
14 | id
15 | name
16 | status
17 | species
18 | type
19 | gender
20 | origin
21 | episode
22 | created
23 | image
24 | location
25 | """)
26 |
27 | # Everything except id
28 | extend type Location @nonnull(fields: """
29 | name
30 | type
31 | dimension
32 | residents
33 | created
34 | """)
35 |
36 | extend type Episode @nonnull(fields: """
37 | id
38 | name
39 | air_date
40 | episode
41 | characters
42 | created
43 | """)
44 |
45 | extend type Characters @nonnull(fields: """
46 | info
47 | results
48 | """)
49 |
50 | extend type Info @nonnull(fields: """
51 | count
52 | pages
53 | prev
54 | """)
55 |
56 | extend type Locations @nonnull(fields: """
57 | info
58 | results
59 | """)
60 |
61 |
62 | extend type Episodes @nonnull(fields: """
63 | info
64 | results
65 | """)
66 |
67 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/dev/johnoreilly/mortycomposekmm/shared/paging/EpisodesDataSource.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.shared.paging
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import dev.johnoreilly.mortycomposekmm.fragment.EpisodeDetail
6 | import dev.johnoreilly.mortycomposekmm.shared.MortyRepository
7 |
8 | class EpisodesDataSource(private val repository: MortyRepository) : PagingSource() {
9 |
10 | override suspend fun load(params: LoadParams): LoadResult {
11 | val pageNumber = params.key ?: 0
12 |
13 | val episodesResponse = repository.getEpisodes(pageNumber)
14 | val episodes = episodesResponse.results.mapNotNull { it?.episodeDetail }
15 |
16 | val prevKey = if (pageNumber > 0) pageNumber - 1 else null
17 | val nextKey = episodesResponse.info.next
18 | return LoadResult.Page(data = episodes, prevKey = prevKey, nextKey = nextKey)
19 | }
20 |
21 | override fun getRefreshKey(state: PagingState): Int? {
22 | return null
23 | }
24 | }
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/dev/johnoreilly/mortycomposekmm/shared/paging/LocationsDataSource.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.shared.paging
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import dev.johnoreilly.mortycomposekmm.fragment.LocationDetail
6 | import dev.johnoreilly.mortycomposekmm.shared.MortyRepository
7 |
8 | class LocationsDataSource(private val repository: MortyRepository) : PagingSource() {
9 |
10 | override suspend fun load(params: LoadParams): LoadResult {
11 | val pageNumber = params.key ?: 0
12 |
13 | val locationsResponse = repository.getLocations(pageNumber)
14 | val episodes = locationsResponse.results.mapNotNull { it?.locationDetail }
15 |
16 | val prevKey = if (pageNumber > 0) pageNumber - 1 else null
17 | val nextKey = locationsResponse.info.next
18 | return LoadResult.Page(data = episodes, prevKey = prevKey, nextKey = nextKey)
19 | }
20 |
21 | override fun getRefreshKey(state: PagingState): Int? {
22 | return null
23 | }
24 | }
--------------------------------------------------------------------------------
/iosApp/iosApp/Features/Characters/CharactersListView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import shared
3 | import KMPObservableViewModelSwiftUI
4 |
5 |
6 | struct CharactersListView: View {
7 | @StateViewModel var viewModel = CharactersViewModel()
8 | @State private var selectedCharacter: CharacterDetail? = nil
9 |
10 | var body: some View {
11 | List {
12 | ForEach(viewModel.charactersSnapshotList.indices, id: \.self) { index in
13 | if let character = viewModel.getElement(index: Int32(index)) {
14 | NavigationLink(
15 | destination: CharacterDetailView(character: character),
16 | tag: character,
17 | selection: $selectedCharacter
18 | ) {
19 | CharactersListRowView(character: character)
20 | .onTapGesture {
21 | selectedCharacter = character
22 | }
23 | }
24 | }
25 | }
26 | }
27 | .navigationTitle("Characters")
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/dev/johnoreilly/mortycomposekmm/shared/paging/CharactersDataSource.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.shared.paging
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import dev.johnoreilly.mortycomposekmm.fragment.CharacterDetail
6 | import dev.johnoreilly.mortycomposekmm.shared.MortyRepository
7 |
8 | class CharactersDataSource(private val repository: MortyRepository) : PagingSource() {
9 |
10 | override suspend fun load(params: LoadParams): LoadResult {
11 | val pageNumber = params.key ?: 0
12 |
13 | val charactersResponse = repository.getCharacters(pageNumber)
14 | val characters = charactersResponse.results.mapNotNull { it?.characterDetail }
15 |
16 | val prevKey = if (pageNumber > 0) pageNumber - 1 else null
17 | val nextKey = charactersResponse.info.next
18 | return LoadResult.Page(data = characters, prevKey = prevKey, nextKey = nextKey)
19 | }
20 |
21 | override fun getRefreshKey(state: PagingState): Int? {
22 | return null
23 | }
24 | }
--------------------------------------------------------------------------------
/iosApp/iosApp/Features/Episodes/EpisodesListRowView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import shared
3 |
4 |
5 | struct EpisodesListRowView: View {
6 | let episode: EpisodeDetail
7 |
8 | var body: some View {
9 | VStack(alignment: .leading, spacing: 4) {
10 | HStack {
11 | VStack(alignment: .leading, spacing: 4) {
12 | Text(episode.name)
13 | .font(.title3)
14 | .fontWeight(.bold)
15 | .foregroundColor(.primary)
16 | .lineLimit(1)
17 |
18 | Text(episode.episode)
19 | .font(.subheadline)
20 | .foregroundColor(.secondary)
21 | }
22 |
23 | Spacer()
24 |
25 | Text(episode.air_date)
26 | .font(.caption)
27 | .foregroundColor(.secondary)
28 | .padding(.leading, 8)
29 | }
30 |
31 | Divider()
32 | .padding(.top, 4)
33 | }
34 | .padding(.vertical, 4)
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/androidApp/src/androidTest/java/MortyConposeTest.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.ui.test.*
2 | import androidx.compose.ui.test.junit4.createComposeRule
3 | import dev.johnoreilly.mortycomposekmm.fragment.CharacterDetail
4 | import dev.johnoreilly.mortycomposekmm.ui.characters.CharactersListRowView
5 | import org.junit.Rule
6 | import org.junit.Test
7 |
8 | class MortyConposeTest {
9 |
10 | @get:Rule
11 | val composeTestRule = createComposeRule()
12 |
13 | val character = CharacterDetail("", "1", "John", "",
14 | "", "", "", emptyList(),
15 | CharacterDetail.Location("", ""), CharacterDetail.Origin(""))
16 |
17 |
18 | @Test
19 | fun testCharacterRow() {
20 | composeTestRule.setContent {
21 | CharactersListRowView(character = character, characterSelected = {})
22 | }
23 | composeTestRule.onRoot(useUnmergedTree = true).printToLog("characterRow")
24 |
25 | composeTestRule
26 | .onNode(
27 | hasText(character.name) and
28 | hasAnySibling(
29 | hasText("0 episode(s)")
30 | ),
31 | useUnmergedTree = true
32 | )
33 | .assertExists()
34 | }
35 | }
--------------------------------------------------------------------------------
/androidApp/src/main/java/dev/johnoreilly/mortycomposekmm/ui/locations/LocationsListView.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.ui.locations
2 |
3 | import androidx.compose.foundation.lazy.LazyColumn
4 | import androidx.compose.runtime.Composable
5 | import androidx.paging.compose.collectAsLazyPagingItems
6 | import androidx.paging.compose.itemContentType
7 | import androidx.paging.compose.itemKey
8 | import dev.johnoreilly.mortycomposekmm.fragment.LocationDetail
9 | import dev.johnoreilly.mortycomposekmm.shared.viewmodel.LocationsViewModel
10 | import org.koin.compose.koinInject
11 |
12 |
13 | @Composable
14 | fun LocationsListView(locationSelected: (location: LocationDetail) -> Unit) {
15 | val viewModel: LocationsViewModel = koinInject()
16 | val lazyLocationsList = viewModel.locationsFlow.collectAsLazyPagingItems()
17 |
18 | LazyColumn {
19 | items(
20 | count = lazyLocationsList.itemCount,
21 | key = lazyLocationsList.itemKey { it.name },
22 | contentType = lazyLocationsList.itemContentType { "MyPagingItems" }
23 | ) { index ->
24 | val location = lazyLocationsList[index]
25 | location?.let {
26 | LocationsListRowView(location, locationSelected)
27 | }
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/androidApp/src/main/java/dev/johnoreilly/mortycomposekmm/ui/episodes/EpisodesListView.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.ui.episodes
2 |
3 | import androidx.compose.foundation.lazy.LazyColumn
4 | import androidx.compose.material.Scaffold
5 | import androidx.compose.material.Text
6 | import androidx.compose.material.TopAppBar
7 | import androidx.compose.runtime.Composable
8 | import androidx.paging.compose.collectAsLazyPagingItems
9 | import androidx.paging.compose.itemContentType
10 | import androidx.paging.compose.itemKey
11 | import dev.johnoreilly.mortycomposekmm.fragment.EpisodeDetail
12 | import dev.johnoreilly.mortycomposekmm.shared.viewmodel.EpisodesViewModel
13 | import org.koin.compose.koinInject
14 |
15 |
16 | @Composable
17 | fun EpisodesListView(episodeSelected: (episode: EpisodeDetail) -> Unit) {
18 | val viewModel: EpisodesViewModel = koinInject()
19 | val lazyEpisodeList = viewModel.episodesFlow.collectAsLazyPagingItems()
20 |
21 | LazyColumn {
22 | items(
23 | count = lazyEpisodeList.itemCount,
24 | key = lazyEpisodeList.itemKey { it.id },
25 | contentType = lazyEpisodeList.itemContentType { "MyPagingItems" }
26 | ) { index ->
27 | val episode = lazyEpisodeList[index]
28 | episode?.let {
29 | EpisodesListRowView(episode, episodeSelected)
30 | }
31 | }
32 | }
33 | }
34 |
35 |
--------------------------------------------------------------------------------
/iosApp/iosAppUITests/iosAppUITests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | class appNameUITests: XCTestCase {
4 |
5 | override func setUp() {
6 | // Put setup code here. This method is called before the invocation of each test method in the class.
7 |
8 | // In UI tests it is usually best to stop immediately when a failure occurs.
9 | continueAfterFailure = false
10 |
11 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
12 | }
13 |
14 | override func tearDown() {
15 | // Put teardown code here. This method is called after the invocation of each test method in the class.
16 | }
17 |
18 | func testExample() {
19 | // UI tests must launch the application that they test.
20 | let app = XCUIApplication()
21 | app.launch()
22 |
23 | // Use recording to get started writing UI tests.
24 | // Use XCTAssert and related functions to verify your tests produce the correct results.
25 | }
26 |
27 | func testLaunchPerformance() {
28 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) {
29 | // This measures how long it takes to launch your application.
30 | measure(metrics: [XCTOSSignpostMetric.applicationLaunch]) {
31 | XCUIApplication().launch()
32 | }
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/iosApp/iosApp/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import shared
3 |
4 | @UIApplicationMain
5 | class AppDelegate: UIResponder, UIApplicationDelegate {
6 |
7 |
8 |
9 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
10 | // Override point for customization after application launch.
11 | KoinKt.doInitKoin()
12 | return true
13 | }
14 |
15 | // MARK: UISceneSession Lifecycle
16 |
17 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
18 | // Called when a new scene session is being created.
19 | // Use this method to select a configuration to create the new scene with.
20 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
21 | }
22 |
23 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {
24 | // Called when the user discards a scene session.
25 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
26 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
27 | }
28 |
29 |
30 | }
31 |
32 |
--------------------------------------------------------------------------------
/shared/src/commonMain/graphql/dev/johnoreilly/mortycomposekmm/Queries.graphql:
--------------------------------------------------------------------------------
1 | query GetCharacters($page: Int) {
2 | characters(page: $page) {
3 | info {
4 | pages, count, next
5 | }
6 | results {
7 | ...CharacterDetail
8 | }
9 | }
10 | }
11 |
12 | query GetCharacter($id: ID!){
13 | character(id: $id) {
14 | ...CharacterDetail
15 | }
16 | }
17 |
18 | query GetEpisodes($page: Int){
19 | episodes(page: $page) {
20 | info {
21 | count, pages, next
22 | }
23 | results {
24 | ...EpisodeDetail
25 | }
26 | }
27 | }
28 |
29 | query GetEpisode($id: ID!) {
30 | episode(id: $id) {
31 | ...EpisodeDetail
32 | }
33 | }
34 |
35 | query GetLocations($page: Int) {
36 | locations(page: $page) {
37 | info {
38 | count, pages, next
39 | }
40 | results {
41 | ...LocationDetail
42 | }
43 | }
44 | }
45 |
46 | query GetLocation($id: ID!) {
47 | location(id: $id) {
48 | ...LocationDetail
49 | }
50 | }
51 |
52 |
53 | fragment CharacterDetail on Character {
54 | id, name, image, status, species, type, gender
55 | episode {
56 | id, name, air_date
57 | }
58 | location {
59 | id, name
60 | }
61 | origin {
62 | name
63 | }
64 | }
65 |
66 |
67 | fragment EpisodeDetail on Episode {
68 | id, name, created, air_date, episode
69 | characters {
70 | id, name, image
71 | }
72 | }
73 |
74 | fragment LocationDetail on Location {
75 | id, name, type, dimension,
76 | residents {
77 | id, name, image
78 | }
79 | }
80 |
81 |
82 |
83 |
84 |
85 |
--------------------------------------------------------------------------------
/androidApp/src/main/java/dev/johnoreilly/mortycomposekmm/ui/locations/LocationsListRowView.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.ui.locations
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.material.*
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.CompositionLocalProvider
8 | import androidx.compose.ui.Alignment
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.text.font.FontWeight
11 | import androidx.compose.ui.text.style.TextOverflow
12 | import androidx.compose.ui.unit.dp
13 | import dev.johnoreilly.mortycomposekmm.fragment.LocationDetail
14 |
15 |
16 | @Composable
17 | fun LocationsListRowView(location: LocationDetail, locationSelected: (location: LocationDetail) -> Unit) {
18 |
19 | Row(modifier = Modifier.fillMaxWidth()
20 | .clickable(onClick = { locationSelected(location) })
21 | .padding(vertical = 8.dp, horizontal = 16.dp),
22 | verticalAlignment = Alignment.CenterVertically
23 | ) {
24 |
25 | Column(modifier = Modifier.weight(1f)) {
26 | Text(
27 | location.name,
28 | style = MaterialTheme.typography.h6,
29 | fontWeight = FontWeight.Bold,
30 | maxLines = 1, overflow = TextOverflow.Ellipsis,)
31 |
32 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
33 | Text(
34 | "${location.residents.size} resident(s)",
35 | style = MaterialTheme.typography.body2
36 | )
37 | }
38 | }
39 | }
40 | Divider()
41 | }
--------------------------------------------------------------------------------
/androidApp/src/main/java/dev/johnoreilly/mortycomposekmm/ui/episodes/EpisodesListRowView.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.ui.episodes
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.material.*
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.CompositionLocalProvider
8 | import androidx.compose.ui.Alignment
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.text.font.FontWeight
11 | import androidx.compose.ui.text.style.TextOverflow
12 | import androidx.compose.ui.unit.dp
13 | import dev.johnoreilly.mortycomposekmm.fragment.EpisodeDetail
14 |
15 |
16 | @Composable
17 | fun EpisodesListRowView(episode: EpisodeDetail, episodeSelected: (episode: EpisodeDetail) -> Unit) {
18 |
19 | Row(modifier = Modifier.fillMaxWidth()
20 | .clickable(onClick = { episodeSelected(episode) })
21 | .padding(vertical = 8.dp, horizontal = 16.dp),
22 | verticalAlignment = Alignment.CenterVertically
23 | ) {
24 |
25 | Column(modifier = Modifier.weight(1f)) {
26 | Text(
27 | episode.name,
28 | style = MaterialTheme.typography.h6,
29 | fontWeight = FontWeight.Bold,
30 | maxLines = 1, overflow = TextOverflow.Ellipsis,)
31 |
32 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
33 | Text(
34 | episode.episode,
35 | style = MaterialTheme.typography.body2
36 | )
37 | }
38 | }
39 |
40 | Text(episode.air_date, modifier = Modifier.padding(start = 16.dp))
41 | }
42 | Divider()
43 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # MortyCompose
2 |
3 | 
4 |
5 |
6 | Kotlin Multiplatform sample that demonstrates use of GraphQL + Jetpack Compose and SwiftUI (based on https://github.com/Dimillian/MortyUI SwiftUI project).
7 | Uses [Apollo library's Kotlin Multiplatform support](https://www.apollographql.com/docs/android/essentials/get-started-multiplatform/) and is also included as one of the samples for that project.
8 |
9 | The project also now makes use of the KMP support now provided by the Jetpack Paging library.
10 |
11 |
12 | Related Posts:
13 | * [Jetpack Compose and GraphQL, a very merry combination!](https://johnoreilly.dev/posts/jetpack-compose-graphql/)
14 |
15 |
16 | ## Android/iOS Apps
17 |
18 |
19 |
20 |
21 |
22 | ## Full set of Kotlin Multiplatform/Compose/SwiftUI samples
23 |
24 | * PeopleInSpace (https://github.com/joreilly/PeopleInSpace)
25 | * GalwayBus (https://github.com/joreilly/GalwayBus)
26 | * Confetti (https://github.com/joreilly/Confetti)
27 | * BikeShare (https://github.com/joreilly/BikeShare)
28 | * FantasyPremierLeague (https://github.com/joreilly/FantasyPremierLeague)
29 | * ClimateTrace (https://github.com/joreilly/ClimateTraceKMP)
30 | * GeminiKMP (https://github.com/joreilly/GeminiKMP)
31 | * MortyComposeKMM (https://github.com/joreilly/MortyComposeKMM)
32 | * StarWars (https://github.com/joreilly/StarWars)
33 | * WordMasterKMP (https://github.com/joreilly/WordMasterKMP)
34 | * Chip-8 (https://github.com/joreilly/chip-8)
35 | * FirebaseAILogicKMPSample (https://github.com/joreilly/FirebaseAILogicKMPSample)
36 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Features/Locations/LocationsListView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import shared
3 | import KMPObservableViewModelSwiftUI
4 |
5 | struct LocationsListView: View {
6 | @StateViewModel var viewModel = LocationsViewModel()
7 | @State private var selectedLocation: LocationDetail? = nil
8 |
9 | var body: some View {
10 | List {
11 | ForEach(viewModel.locationsSnapshotList.indices, id: \.self) { index in
12 | if let location = viewModel.getElement(index: Int32(index)) {
13 | NavigationLink(
14 | destination: LocationDetailView(location: location),
15 | tag: location,
16 | selection: $selectedLocation
17 | ) {
18 | LocationsListRowView(location: location)
19 | .onTapGesture {
20 | selectedLocation = location
21 | }
22 | }
23 | }
24 | }
25 | }
26 | .navigationTitle("Locations")
27 | }
28 | }
29 |
30 | struct LocationDetailView: View {
31 | let location: LocationDetail
32 |
33 | var body: some View {
34 | VStack(alignment: .leading, spacing: 16) {
35 | Text(location.name)
36 | .font(.largeTitle)
37 | .fontWeight(.bold)
38 |
39 | Group {
40 | Text("Type: \(location.type)")
41 | Text("Dimension: \(location.dimension)")
42 | Text("Residents: \(location.residents.count)")
43 | }
44 | .font(.body)
45 |
46 | Spacer()
47 | }
48 | .padding()
49 | .navigationTitle(location.name)
50 | }
51 | }
--------------------------------------------------------------------------------
/shared/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.kotlinMultiplatform)
3 | alias(libs.plugins.android.library)
4 | alias(libs.plugins.apollo)
5 | alias(libs.plugins.ksp)
6 | alias(libs.plugins.kmpNativeCoroutines)
7 | }
8 |
9 | kotlin {
10 | jvmToolchain(17)
11 |
12 | androidTarget()
13 |
14 | listOf(
15 | iosX64(),
16 | iosArm64(),
17 | iosSimulatorArm64()
18 | ).forEach {
19 | it.binaries.framework {
20 | baseName = "shared"
21 | }
22 | }
23 |
24 | sourceSets {
25 | all {
26 | languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi")
27 | }
28 |
29 | commonMain {
30 | dependencies {
31 | implementation(libs.kotlinx.coroutines)
32 | implementation(libs.koin.core)
33 |
34 | api(libs.apollo.runtime)
35 | implementation(libs.apollo.normalized.cache)
36 | implementation(libs.apollo.normalized.cache.sqlite)
37 |
38 | implementation(libs.androidx.paging.common)
39 | api(libs.kmpObservableViewModel)
40 | }
41 | }
42 | }
43 | }
44 |
45 | android {
46 | compileSdk = libs.versions.compileSdk.get().toInt()
47 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
48 | defaultConfig {
49 | minSdk = libs.versions.minSdk.get().toInt()
50 | }
51 |
52 | compileOptions {
53 | sourceCompatibility = JavaVersion.VERSION_17
54 | targetCompatibility = JavaVersion.VERSION_17
55 | }
56 |
57 | namespace = "dev.johnoreilly.mortycomposekmm.shared"
58 | }
59 |
60 |
61 | apollo {
62 | service("service") {
63 | packageName.set("dev.johnoreilly.mortycomposekmm")
64 | generateOptionalOperationVariables.set(false)
65 | }
66 | }
67 |
68 | kotlin.sourceSets.all {
69 | languageSettings.optIn("kotlin.experimental.ExperimentalObjCName")
70 | }
71 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "size" : "20x20",
6 | "scale" : "2x"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "size" : "20x20",
11 | "scale" : "3x"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "size" : "29x29",
16 | "scale" : "2x"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "size" : "29x29",
21 | "scale" : "3x"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "size" : "40x40",
26 | "scale" : "2x"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "size" : "40x40",
31 | "scale" : "3x"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "size" : "60x60",
36 | "scale" : "2x"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "size" : "60x60",
41 | "scale" : "3x"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "size" : "20x20",
46 | "scale" : "1x"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "size" : "20x20",
51 | "scale" : "2x"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "size" : "29x29",
56 | "scale" : "1x"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "size" : "29x29",
61 | "scale" : "2x"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "size" : "40x40",
66 | "scale" : "1x"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "size" : "40x40",
71 | "scale" : "2x"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "size" : "76x76",
76 | "scale" : "1x"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "size" : "76x76",
81 | "scale" : "2x"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "size" : "83.5x83.5",
86 | "scale" : "2x"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "size" : "1024x1024",
91 | "scale" : "1x"
92 | }
93 | ],
94 | "info" : {
95 | "version" : 1,
96 | "author" : "xcode"
97 | }
98 | }
--------------------------------------------------------------------------------
/iosApp/iosApp/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UIApplicationSceneManifest
24 |
25 | UIApplicationSupportsMultipleScenes
26 |
27 | UISceneConfigurations
28 |
29 | UIWindowSceneSessionRoleApplication
30 |
31 |
32 | UISceneConfigurationName
33 | Default Configuration
34 | UISceneDelegateClassName
35 | $(PRODUCT_MODULE_NAME).SceneDelegate
36 |
37 |
38 |
39 |
40 | UILaunchStoryboardName
41 | LaunchScreen
42 | UIRequiredDeviceCapabilities
43 |
44 | armv7
45 |
46 | UISupportedInterfaceOrientations
47 |
48 | UIInterfaceOrientationPortrait
49 | UIInterfaceOrientationLandscapeLeft
50 | UIInterfaceOrientationLandscapeRight
51 |
52 | UISupportedInterfaceOrientations~ipad
53 |
54 | UIInterfaceOrientationPortrait
55 | UIInterfaceOrientationPortraitUpsideDown
56 | UIInterfaceOrientationLandscapeLeft
57 | UIInterfaceOrientationLandscapeRight
58 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Features/Episodes/EpisodesListView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import shared
3 | import KMPObservableViewModelSwiftUI
4 |
5 | struct EpisodesListView: View {
6 | @StateViewModel var viewModel = EpisodesViewModel()
7 | @State private var selectedEpisode: EpisodeDetail? = nil
8 |
9 | var body: some View {
10 | List {
11 | ForEach(viewModel.episodesSnapshotList.indices, id: \.self) { index in
12 | if let episode = viewModel.getElement(index: Int32(index)) {
13 | NavigationLink(
14 | destination: EpisodeDetailView(episode: episode),
15 | tag: episode,
16 | selection: $selectedEpisode
17 | ) {
18 | EpisodesListRowView(episode: episode)
19 | .onTapGesture {
20 | selectedEpisode = episode
21 | }
22 | }
23 | }
24 | }
25 | }
26 | .navigationTitle("Episodes")
27 | }
28 | }
29 |
30 | struct EpisodeDetailView: View {
31 | let episode: EpisodeDetail
32 |
33 | var body: some View {
34 | ScrollView {
35 | VStack(alignment: .leading, spacing: 16) {
36 | // Episode info
37 | VStack(alignment: .leading, spacing: 8) {
38 | Text(episode.name)
39 | .font(.title)
40 | .fontWeight(.bold)
41 |
42 | Text(episode.episode)
43 | .font(.headline)
44 | .foregroundColor(.secondary)
45 |
46 | Text("Air date: \(episode.air_date)")
47 | .font(.subheadline)
48 | }
49 | .padding(.horizontal)
50 |
51 | Divider()
52 |
53 | // Characters
54 | VStack(alignment: .leading, spacing: 8) {
55 | Text("Characters:")
56 | .font(.headline)
57 |
58 | Text("This episode features \(episode.characters.count) character(s)")
59 | .font(.subheadline)
60 | }
61 | .padding(.horizontal)
62 |
63 | Spacer()
64 | }
65 | .padding(.vertical)
66 | }
67 | .navigationTitle(episode.name)
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/dev/johnoreilly/mortycomposekmm/shared/viewmodel/EpisodesViewModel.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.shared.viewmodel
2 |
3 | import androidx.paging.ItemSnapshotList
4 | import androidx.paging.Pager
5 | import androidx.paging.PagingConfig
6 | import androidx.paging.PagingData
7 | import androidx.paging.PagingDataEvent
8 | import androidx.paging.PagingDataPresenter
9 | import androidx.paging.cachedIn
10 | import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState
11 | import com.rickclephas.kmp.observableviewmodel.MutableStateFlow
12 | import com.rickclephas.kmp.observableviewmodel.ViewModel
13 | import com.rickclephas.kmp.observableviewmodel.coroutineScope
14 | import dev.johnoreilly.mortycomposekmm.fragment.EpisodeDetail
15 | import dev.johnoreilly.mortycomposekmm.shared.MortyRepository
16 | import dev.johnoreilly.mortycomposekmm.shared.paging.EpisodesDataSource
17 | import kotlinx.coroutines.flow.Flow
18 | import kotlinx.coroutines.flow.MutableStateFlow
19 | import kotlinx.coroutines.flow.collectLatest
20 | import kotlinx.coroutines.launch
21 | import org.koin.core.component.KoinComponent
22 | import org.koin.core.component.inject
23 |
24 |
25 | open class EpisodesViewModel(): ViewModel(), KoinComponent {
26 | private val repository: MortyRepository by inject()
27 |
28 | val episodesFlow: Flow> = Pager(PagingConfig(pageSize = 20)) {
29 | EpisodesDataSource(repository)
30 | }.flow.cachedIn(viewModelScope.coroutineScope)
31 |
32 |
33 | private val episodesPagingDataPresenter = object : PagingDataPresenter() {
34 | override suspend fun presentPagingDataEvent(event: PagingDataEvent) {
35 | updateEpisodesSnapshotList()
36 | }
37 | }
38 |
39 | @NativeCoroutinesState
40 | val episodesSnapshotList: MutableStateFlow> = MutableStateFlow>(viewModelScope, episodesPagingDataPresenter.snapshot())
41 |
42 | init {
43 | viewModelScope.coroutineScope.launch {
44 | episodesFlow.collectLatest {
45 | episodesPagingDataPresenter.collectFrom(it)
46 | }
47 | }
48 | }
49 |
50 | private fun updateEpisodesSnapshotList() {
51 | episodesSnapshotList.value = episodesPagingDataPresenter.snapshot()
52 | }
53 |
54 | fun getElement(index: Int): EpisodeDetail? {
55 | return episodesPagingDataPresenter.get(index)
56 | }
57 |
58 | suspend fun getEpisode(episodeId: String): EpisodeDetail {
59 | return repository.getEpisode(episodeId)
60 | }
61 | }
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/dev/johnoreilly/mortycomposekmm/shared/viewmodel/LocationsViewModel.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.shared.viewmodel
2 |
3 | import androidx.paging.ItemSnapshotList
4 | import androidx.paging.Pager
5 | import androidx.paging.PagingConfig
6 | import androidx.paging.PagingData
7 | import androidx.paging.PagingDataEvent
8 | import androidx.paging.PagingDataPresenter
9 | import androidx.paging.cachedIn
10 | import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState
11 | import com.rickclephas.kmp.observableviewmodel.MutableStateFlow
12 | import com.rickclephas.kmp.observableviewmodel.ViewModel
13 | import com.rickclephas.kmp.observableviewmodel.coroutineScope
14 | import dev.johnoreilly.mortycomposekmm.fragment.LocationDetail
15 | import dev.johnoreilly.mortycomposekmm.shared.MortyRepository
16 | import dev.johnoreilly.mortycomposekmm.shared.paging.LocationsDataSource
17 | import kotlinx.coroutines.flow.Flow
18 | import kotlinx.coroutines.flow.MutableStateFlow
19 | import kotlinx.coroutines.flow.collectLatest
20 | import kotlinx.coroutines.launch
21 | import org.koin.core.component.KoinComponent
22 | import org.koin.core.component.inject
23 |
24 |
25 | open class LocationsViewModel(): ViewModel(), KoinComponent {
26 | private val repository: MortyRepository by inject()
27 |
28 | val locationsFlow: Flow> = Pager(PagingConfig(pageSize = 20)) {
29 | LocationsDataSource(repository)
30 | }.flow.cachedIn(viewModelScope.coroutineScope)
31 |
32 |
33 | private val locationsPagingDataPresenter = object : PagingDataPresenter() {
34 | override suspend fun presentPagingDataEvent(event: PagingDataEvent) {
35 | updateLocationsSnapshotList()
36 | }
37 | }
38 |
39 | @NativeCoroutinesState
40 | val locationsSnapshotList: MutableStateFlow> = MutableStateFlow>(viewModelScope, locationsPagingDataPresenter.snapshot())
41 |
42 | init {
43 | viewModelScope.coroutineScope.launch {
44 | locationsFlow.collectLatest {
45 | locationsPagingDataPresenter.collectFrom(it)
46 | }
47 | }
48 | }
49 |
50 | private fun updateLocationsSnapshotList() {
51 | locationsSnapshotList.value = locationsPagingDataPresenter.snapshot()
52 | }
53 |
54 | fun getElement(index: Int): LocationDetail? {
55 | return locationsPagingDataPresenter.get(index)
56 | }
57 |
58 | suspend fun getLocation(episodeId: String): LocationDetail {
59 | return repository.getLocation(episodeId)
60 | }
61 | }
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/dev/johnoreilly/mortycomposekmm/shared/viewmodel/CharactersViewModel.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.shared.viewmodel
2 |
3 | import androidx.paging.ItemSnapshotList
4 | import androidx.paging.Pager
5 | import androidx.paging.PagingConfig
6 | import androidx.paging.PagingData
7 | import androidx.paging.PagingDataEvent
8 | import androidx.paging.PagingDataPresenter
9 | import androidx.paging.cachedIn
10 | import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState
11 | import com.rickclephas.kmp.observableviewmodel.MutableStateFlow
12 | import com.rickclephas.kmp.observableviewmodel.ViewModel
13 | import com.rickclephas.kmp.observableviewmodel.coroutineScope
14 | import dev.johnoreilly.mortycomposekmm.fragment.CharacterDetail
15 | import dev.johnoreilly.mortycomposekmm.shared.MortyRepository
16 | import dev.johnoreilly.mortycomposekmm.shared.paging.CharactersDataSource
17 | import kotlinx.coroutines.flow.Flow
18 | import kotlinx.coroutines.flow.MutableStateFlow
19 | import kotlinx.coroutines.flow.collectLatest
20 | import kotlinx.coroutines.launch
21 | import org.koin.core.component.KoinComponent
22 | import org.koin.core.component.inject
23 |
24 |
25 | open class CharactersViewModel(): ViewModel(), KoinComponent {
26 | private val repository: MortyRepository by inject()
27 |
28 | val charactersFlow: Flow> = Pager(PagingConfig(pageSize = 20)) {
29 | CharactersDataSource(repository)
30 | }.flow.cachedIn(viewModelScope.coroutineScope)
31 |
32 |
33 | private val charactersPagingDataPresenter = object : PagingDataPresenter() {
34 | override suspend fun presentPagingDataEvent(event: PagingDataEvent) {
35 | updateCharactersSnapshotList()
36 | }
37 | }
38 |
39 | @NativeCoroutinesState
40 | val charactersSnapshotList: MutableStateFlow> = MutableStateFlow>(viewModelScope, charactersPagingDataPresenter.snapshot())
41 |
42 | init {
43 | viewModelScope.coroutineScope.launch {
44 | charactersFlow.collectLatest {
45 | charactersPagingDataPresenter.collectFrom(it)
46 | }
47 | }
48 | }
49 |
50 | private fun updateCharactersSnapshotList() {
51 | charactersSnapshotList.value = charactersPagingDataPresenter.snapshot()
52 | }
53 |
54 | fun getElement(index: Int): CharacterDetail? {
55 | return charactersPagingDataPresenter.get(index)
56 | }
57 |
58 | suspend fun getCharacter(characterId: String): CharacterDetail {
59 | return repository.getCharacter(characterId)
60 | }
61 |
62 | }
--------------------------------------------------------------------------------
/shared/src/commonMain/kotlin/dev/johnoreilly/mortycomposekmm/shared/MortyRepository.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.shared
2 |
3 | import com.apollographql.apollo.ApolloClient
4 | import com.apollographql.apollo.cache.normalized.api.MemoryCacheFactory
5 | import com.apollographql.apollo.cache.normalized.normalizedCache
6 | import dev.johnoreilly.mortycomposekmm.GetCharacterQuery
7 | import dev.johnoreilly.mortycomposekmm.GetCharactersQuery
8 | import dev.johnoreilly.mortycomposekmm.GetEpisodeQuery
9 | import dev.johnoreilly.mortycomposekmm.GetEpisodesQuery
10 | import dev.johnoreilly.mortycomposekmm.GetLocationQuery
11 | import dev.johnoreilly.mortycomposekmm.GetLocationsQuery
12 | import dev.johnoreilly.mortycomposekmm.fragment.CharacterDetail
13 | import dev.johnoreilly.mortycomposekmm.fragment.EpisodeDetail
14 | import dev.johnoreilly.mortycomposekmm.fragment.LocationDetail
15 |
16 |
17 | class MortyRepository {
18 | // Creates a 10MB MemoryCacheFactory
19 | val cacheFactory = MemoryCacheFactory(maxSizeBytes = 10 * 1024 * 1024)
20 |
21 | // TODO use persistent cache as well, also inject apolloClient
22 |
23 | private val apolloClient = ApolloClient.Builder()
24 | .serverUrl("https://rickandmortyapi.com/graphql")
25 | .normalizedCache(cacheFactory)
26 | .build()
27 |
28 | suspend fun getCharacters(page: Int): GetCharactersQuery.Characters {
29 | val response = apolloClient.query(GetCharactersQuery(page)).execute()
30 | return response.dataAssertNoErrors.characters
31 | }
32 |
33 | suspend fun getCharacter(characterId: String): CharacterDetail {
34 | val response = apolloClient.query(GetCharacterQuery(characterId)).execute()
35 | return response.dataAssertNoErrors.character.characterDetail
36 | }
37 |
38 | suspend fun getEpisodes(page: Int): GetEpisodesQuery.Episodes {
39 | val response = apolloClient.query(GetEpisodesQuery(page)).execute()
40 | return response.dataAssertNoErrors.episodes
41 | }
42 |
43 | suspend fun getEpisode(episodeId: String): EpisodeDetail {
44 | val response = apolloClient.query(GetEpisodeQuery(episodeId)).execute()
45 | return response.dataAssertNoErrors.episode.episodeDetail
46 | }
47 |
48 | suspend fun getLocations(page: Int): GetLocationsQuery.Locations {
49 | val response = apolloClient.query(GetLocationsQuery(page)).execute()
50 | return response.dataAssertNoErrors.locations
51 | }
52 |
53 | suspend fun getLocation(locationId: String): LocationDetail {
54 | val response = apolloClient.query(GetLocationQuery(locationId)).execute()
55 | return response.dataAssertNoErrors.location.locationDetail
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/iosApp/iosApp/SceneDelegate.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import SwiftUI
3 |
4 | class SceneDelegate: UIResponder, UIWindowSceneDelegate {
5 |
6 | var window: UIWindow?
7 |
8 |
9 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
10 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
11 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
12 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
13 |
14 | // Create the SwiftUI view that provides the window contents.
15 | let contentView = ContentView()
16 |
17 | // Use a UIHostingController as window root view controller.
18 | if let windowScene = scene as? UIWindowScene {
19 | let window = UIWindow(windowScene: windowScene)
20 | window.rootViewController = UIHostingController(rootView: contentView)
21 | self.window = window
22 | window.makeKeyAndVisible()
23 | }
24 | }
25 |
26 | func sceneDidDisconnect(_ scene: UIScene) {
27 | // Called as the scene is being released by the system.
28 | // This occurs shortly after the scene enters the background, or when its session is discarded.
29 | // Release any resources associated with this scene that can be re-created the next time the scene connects.
30 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
31 | }
32 |
33 | func sceneDidBecomeActive(_ scene: UIScene) {
34 | // Called when the scene has moved from an inactive state to an active state.
35 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
36 | }
37 |
38 | func sceneWillResignActive(_ scene: UIScene) {
39 | // Called when the scene will move from an active state to an inactive state.
40 | // This may occur due to temporary interruptions (ex. an incoming phone call).
41 | }
42 |
43 | func sceneWillEnterForeground(_ scene: UIScene) {
44 | // Called as the scene transitions from the background to the foreground.
45 | // Use this method to undo the changes made on entering the background.
46 | }
47 |
48 | func sceneDidEnterBackground(_ scene: UIScene) {
49 | // Called as the scene transitions from the foreground to the background.
50 | // Use this method to save data, release shared resources, and store enough scene-specific state information
51 | // to restore the scene back to its current state.
52 | }
53 |
54 |
55 | }
56 |
57 |
--------------------------------------------------------------------------------
/androidApp/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.application")
3 | kotlin("android")
4 | alias(libs.plugins.compose.compiler)
5 | }
6 |
7 | android {
8 | compileSdk = libs.versions.compileSdk.get().toInt()
9 | defaultConfig {
10 | applicationId = "dev.johnoreilly.mortyuicomposekmp"
11 | minSdk = libs.versions.minSdk.get().toInt()
12 | targetSdk = libs.versions.targetSdk.get().toInt()
13 |
14 | versionCode = 1
15 | versionName = "1.0"
16 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
17 | }
18 |
19 | buildFeatures {
20 | compose = true
21 | buildConfig = true
22 | }
23 |
24 | buildTypes {
25 | getByName("release") {
26 | isMinifyEnabled = true
27 | isShrinkResources = true
28 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
29 | }
30 | }
31 |
32 | compileOptions {
33 | sourceCompatibility = JavaVersion.VERSION_17
34 | targetCompatibility = JavaVersion.VERSION_17
35 | }
36 |
37 | kotlinOptions {
38 | freeCompilerArgs += listOf(
39 | "-P",
40 | "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true"
41 | )
42 | }
43 | namespace = "dev.johnoreilly.mortycomposekmm"
44 | }
45 |
46 | dependencies {
47 | implementation(libs.androidx.activity.compose)
48 |
49 | implementation(platform(libs.androidx.compose.bom))
50 | implementation(libs.androidx.compose.foundation)
51 | implementation(libs.androidx.compose.foundation.layout)
52 | implementation(libs.androidx.compose.material)
53 | implementation(libs.androidx.compose.material.iconsExtended)
54 | implementation(libs.androidx.compose.material3)
55 | implementation(libs.androidx.compose.material3.windowSizeClass)
56 | implementation(libs.androidx.compose.runtime)
57 | implementation(libs.androidx.compose.ui)
58 | implementation(libs.androidx.compose.ui.tooling)
59 |
60 | implementation(libs.androidx.paging.compose)
61 | implementation(libs.androidx.navigation.compose)
62 | implementation(libs.coilCompose)
63 |
64 | implementation(libs.koin.core)
65 | implementation(libs.koin.android)
66 | implementation(libs.koin.androidx.compose)
67 |
68 | testImplementation(libs.junit)
69 |
70 |
71 | androidTestImplementation(platform(libs.androidx.compose.bom))
72 | androidTestImplementation(libs.androidx.compose.ui.test)
73 | androidTestImplementation(libs.androidx.compose.ui.test.junit)
74 | debugImplementation(libs.androidx.compose.ui.test.manifest)
75 |
76 | testImplementation("androidx.test:core:1.6.1")
77 | testImplementation("org.robolectric:robolectric:4.13")
78 | androidTestImplementation("androidx.test:runner:1.6.2")
79 |
80 | implementation(project(":shared"))
81 | }
82 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Features/Characters/CharactersListRowView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import shared
3 |
4 |
5 | struct CharactersListRowView: View {
6 | let character: CharacterDetail
7 |
8 | var body: some View {
9 | HStack(spacing: 16) {
10 | // Character image with circular frame
11 | ZStack {
12 | Circle()
13 | .fill(LinearGradient(
14 | gradient: Gradient(colors: [Color.green.opacity(0.7), Color.blue.opacity(0.3)]),
15 | startPoint: .topLeading,
16 | endPoint: .bottomTrailing
17 | ))
18 | .frame(width: 80, height: 80)
19 |
20 | AsyncImage(url: URL(string: character.image)) { image in
21 | image.resizable()
22 | } placeholder: {
23 | ProgressView()
24 | }
25 | .frame(width: 70, height: 70)
26 | .clipShape(Circle())
27 | .overlay(
28 | Circle()
29 | .stroke(Color.white, lineWidth: 2)
30 | )
31 | }
32 |
33 | // Character info
34 | VStack(alignment: .leading, spacing: 4) {
35 | Text(character.name)
36 | .font(.title3)
37 | .fontWeight(.bold)
38 | .foregroundColor(.primary)
39 | .lineLimit(1)
40 |
41 | // Status indicator
42 | HStack(spacing: 4) {
43 | Circle()
44 | .fill(character.status == "Alive" ? Color.green :
45 | character.status == "Dead" ? Color.red : Color.gray)
46 | .frame(width: 8, height: 8)
47 | Text("\(character.status) - \(character.species)")
48 | .font(.caption)
49 | .foregroundColor(.secondary)
50 | }
51 | .padding(.vertical, 4)
52 |
53 | // Location
54 | Text("Last location: \(character.location.name)")
55 | .font(.caption)
56 | .foregroundColor(.secondary)
57 | .lineLimit(1)
58 |
59 | // Episodes count
60 | HStack {
61 | Text("Episodes:")
62 | .font(.caption)
63 | .foregroundColor(.secondary)
64 |
65 | Text("\(character.episode.count)")
66 | .font(.caption)
67 | .fontWeight(.bold)
68 | .foregroundColor(.blue)
69 | .padding(.horizontal, 6)
70 | .padding(.vertical, 2)
71 | .background(Color.blue.opacity(0.2))
72 | .cornerRadius(4)
73 | }
74 | .padding(.top, 4)
75 | }
76 | }
77 | .padding(.vertical, 8)
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/androidApp/src/main/java/dev/johnoreilly/mortycomposekmm/ui/characters/CharactersListView.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.ui.characters
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.foundation.lazy.LazyColumn
5 | import androidx.compose.material3.CircularProgressIndicator
6 | import androidx.compose.material3.MaterialTheme
7 | import androidx.compose.material3.Text
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Alignment
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.text.style.TextAlign
12 | import androidx.compose.ui.unit.dp
13 | import androidx.paging.LoadState
14 | import androidx.paging.compose.collectAsLazyPagingItems
15 | import androidx.paging.compose.itemContentType
16 | import androidx.paging.compose.itemKey
17 | import dev.johnoreilly.mortycomposekmm.fragment.CharacterDetail
18 | import dev.johnoreilly.mortycomposekmm.shared.viewmodel.CharactersViewModel
19 | import dev.johnoreilly.mortycomposekmm.ui.components.PortalErrorScreen
20 | import dev.johnoreilly.mortycomposekmm.ui.components.PortalLoadingAnimation
21 | import dev.johnoreilly.mortycomposekmm.ui.components.SmallErrorIndicator
22 | import dev.johnoreilly.mortycomposekmm.ui.components.SmallPortalLoadingAnimation
23 | import org.koin.compose.koinInject
24 |
25 |
26 | @Composable
27 | fun CharactersListView(characterSelected: (character: CharacterDetail) -> Unit) {
28 | val viewModel: CharactersViewModel = koinInject()
29 | val lazyCharacterList = viewModel.charactersFlow.collectAsLazyPagingItems()
30 |
31 | Box(modifier = Modifier.fillMaxSize()) {
32 | LazyColumn(
33 | contentPadding = PaddingValues(vertical = 8.dp),
34 | verticalArrangement = Arrangement.spacedBy(4.dp)
35 | ) {
36 | items(
37 | count = lazyCharacterList.itemCount,
38 | key = lazyCharacterList.itemKey { it.id },
39 | contentType = lazyCharacterList.itemContentType { "MyPagingItems" }
40 | ) { index ->
41 | val character = lazyCharacterList[index]
42 | character?.let {
43 | CharactersListRowView(character, characterSelected)
44 | }
45 | }
46 |
47 | // Add loading footer when more items are being loaded
48 | item {
49 | when (lazyCharacterList.loadState.append) {
50 | is LoadState.Loading -> {
51 | Box(
52 | modifier = Modifier
53 | .fillMaxWidth()
54 | .padding(16.dp),
55 | contentAlignment = Alignment.Center
56 | ) {
57 | SmallPortalLoadingAnimation()
58 | }
59 | }
60 | is LoadState.Error -> {
61 | SmallErrorIndicator(
62 | message = "Could not load more characters",
63 | onRetry = { lazyCharacterList.retry() }
64 | )
65 | }
66 | else -> {}
67 | }
68 | }
69 | }
70 |
71 | // Show loading state for initial load
72 | when (lazyCharacterList.loadState.refresh) {
73 | is LoadState.Loading -> {
74 | Box(
75 | modifier = Modifier.fillMaxSize(),
76 | contentAlignment = Alignment.Center
77 | ) {
78 | PortalLoadingAnimation()
79 | }
80 | }
81 | is LoadState.Error -> {
82 | Box(
83 | modifier = Modifier.fillMaxSize(),
84 | contentAlignment = Alignment.Center
85 | ) {
86 | PortalErrorScreen(
87 | message = "Could not load characters",
88 | onRetry = { lazyCharacterList.retry() }
89 | )
90 | }
91 | }
92 | else -> {}
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/androidApp/src/main/java/dev/johnoreilly/mortycomposekmm/ui/components/MortyTopAppBar.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.ui.components
2 |
3 | import androidx.compose.foundation.layout.RowScope
4 | import androidx.compose.foundation.layout.statusBarsPadding
5 | import androidx.compose.material.icons.Icons
6 | import androidx.compose.material.icons.filled.ArrowBack
7 | import androidx.compose.material3.*
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.graphics.vector.ImageVector
11 | import androidx.compose.ui.text.style.TextOverflow
12 |
13 | /**
14 | * A shared top app bar component for the Morty app with consistent styling.
15 | * This version is for main screens without a back button.
16 | */
17 | @OptIn(ExperimentalMaterial3Api::class)
18 | @Composable
19 | fun MortyTopAppBar(
20 | title: String,
21 | actions: @Composable RowScope.() -> Unit = {}
22 | ) {
23 | TopAppBar(
24 | title = {
25 | Text(
26 | text = title,
27 | maxLines = 1,
28 | overflow = TextOverflow.Ellipsis
29 | )
30 | },
31 | colors = TopAppBarDefaults.topAppBarColors(
32 | containerColor = MaterialTheme.colorScheme.primaryContainer,
33 | titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
34 | ),
35 | actions = actions
36 | )
37 | }
38 |
39 | /**
40 | * A shared top app bar component for the Morty app with consistent styling.
41 | * This version is for detail screens with a back button.
42 | */
43 | @OptIn(ExperimentalMaterial3Api::class)
44 | @Composable
45 | fun MortyDetailTopAppBar(
46 | title: String,
47 | onBackClick: () -> Unit,
48 | actions: @Composable RowScope.() -> Unit = {}
49 | ) {
50 | TopAppBar(
51 | title = {
52 | Text(
53 | text = title,
54 | maxLines = 1,
55 | overflow = TextOverflow.Ellipsis
56 | )
57 | },
58 | navigationIcon = {
59 | IconButton(onClick = onBackClick) {
60 | Icon(
61 | imageVector = Icons.Filled.ArrowBack,
62 | contentDescription = "Back"
63 | )
64 | }
65 | },
66 | colors = TopAppBarDefaults.topAppBarColors(
67 | containerColor = MaterialTheme.colorScheme.primaryContainer,
68 | titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
69 | navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
70 | ),
71 | actions = actions
72 | )
73 | }
74 |
75 | /**
76 | * A shared top app bar component for the Morty app with consistent styling.
77 | * This version allows for a custom navigation icon.
78 | */
79 | @OptIn(ExperimentalMaterial3Api::class)
80 | @Composable
81 | fun MortyCustomTopAppBar(
82 | title: String,
83 | navigationIcon: ImageVector? = null,
84 | navigationIconContentDescription: String? = null,
85 | onNavigationClick: (() -> Unit)? = null,
86 | actions: @Composable RowScope.() -> Unit = {}
87 | ) {
88 | TopAppBar(
89 | title = {
90 | Text(
91 | text = title,
92 | maxLines = 1,
93 | overflow = TextOverflow.Ellipsis
94 | )
95 | },
96 | navigationIcon = {
97 | if (navigationIcon != null && onNavigationClick != null) {
98 | IconButton(onClick = onNavigationClick) {
99 | Icon(
100 | imageVector = navigationIcon,
101 | contentDescription = navigationIconContentDescription
102 | )
103 | }
104 | }
105 | },
106 | modifier = Modifier.statusBarsPadding(),
107 | colors = TopAppBarDefaults.topAppBarColors(
108 | containerColor = MaterialTheme.colorScheme.primaryContainer,
109 | titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
110 | navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
111 | actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
112 | ),
113 | actions = actions
114 | )
115 | }
--------------------------------------------------------------------------------
/androidApp/src/main/java/dev/johnoreilly/mortycomposekmm/ui/components/LoadingAnimations.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.ui.components
2 |
3 | import androidx.compose.animation.core.*
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.layout.*
6 | import androidx.compose.foundation.shape.CircleShape
7 | import androidx.compose.material3.MaterialTheme
8 | import androidx.compose.material3.Text
9 | import androidx.compose.runtime.*
10 | import androidx.compose.ui.Alignment
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.draw.scale
13 | import androidx.compose.ui.graphics.Brush
14 | import androidx.compose.ui.graphics.Color
15 | import androidx.compose.ui.text.font.FontWeight
16 | import androidx.compose.ui.unit.dp
17 | import dev.johnoreilly.mortycomposekmm.ui.theme.PortalGreen
18 | import dev.johnoreilly.mortycomposekmm.ui.theme.PortalSwirl
19 |
20 | /**
21 | * A portal-themed loading animation for Rick and Morty app
22 | */
23 | @Composable
24 | fun PortalLoadingAnimation(
25 | modifier: Modifier = Modifier,
26 | size: Float = 200f,
27 | showText: Boolean = true
28 | ) {
29 | // Create pulsating animation
30 | val infiniteTransition = rememberInfiniteTransition()
31 |
32 | // Scale animation for the outer portal
33 | val outerScale by infiniteTransition.animateFloat(
34 | initialValue = 0.8f,
35 | targetValue = 1.2f,
36 | animationSpec = infiniteRepeatable(
37 | animation = tween(1000, easing = FastOutSlowInEasing),
38 | repeatMode = RepeatMode.Reverse
39 | )
40 | )
41 |
42 | // Scale animation for the inner portal
43 | val innerScale by infiniteTransition.animateFloat(
44 | initialValue = 0.7f,
45 | targetValue = 1.3f,
46 | animationSpec = infiniteRepeatable(
47 | animation = tween(800, easing = FastOutSlowInEasing),
48 | repeatMode = RepeatMode.Reverse
49 | )
50 | )
51 |
52 | // Rotation animation
53 | val rotation by infiniteTransition.animateFloat(
54 | initialValue = 0f,
55 | targetValue = 360f,
56 | animationSpec = infiniteRepeatable(
57 | animation = tween(3000, easing = LinearEasing)
58 | )
59 | )
60 |
61 | Box(
62 | modifier = modifier.size(size.dp),
63 | contentAlignment = Alignment.Center
64 | ) {
65 | // Outer portal swirl
66 | Box(
67 | modifier = Modifier
68 | .size((size * 0.8f).dp)
69 | .scale(outerScale)
70 | .background(
71 | brush = Brush.radialGradient(
72 | colors = listOf(
73 | PortalGreen,
74 | PortalSwirl.copy(alpha = 0.7f),
75 | PortalGreen.copy(alpha = 0.3f)
76 | )
77 | ),
78 | shape = CircleShape
79 | )
80 | )
81 |
82 | // Inner portal swirl
83 | Box(
84 | modifier = Modifier
85 | .size((size * 0.6f).dp)
86 | .scale(innerScale)
87 | .background(
88 | brush = Brush.radialGradient(
89 | colors = listOf(
90 | PortalSwirl,
91 | PortalGreen,
92 | PortalSwirl.copy(alpha = 0.5f)
93 | )
94 | ),
95 | shape = CircleShape
96 | )
97 | )
98 |
99 | // Loading text
100 | if (showText) {
101 | Text(
102 | text = "Loading...",
103 | style = MaterialTheme.typography.bodyLarge,
104 | fontWeight = FontWeight.Bold,
105 | color = Color.White,
106 | modifier = Modifier.padding(top = (size * 0.9f).dp)
107 | )
108 | }
109 | }
110 | }
111 |
112 | /**
113 | * A smaller version of the portal loading animation for use in lists
114 | */
115 | @Composable
116 | fun SmallPortalLoadingAnimation() {
117 | PortalLoadingAnimation(
118 | size = 60f,
119 | showText = false
120 | )
121 | }
--------------------------------------------------------------------------------
/shared/src/commonMain/graphql/dev/johnoreilly/mortycomposekmm/schema.graphqls:
--------------------------------------------------------------------------------
1 | type Query {
2 | """
3 | Get a specific character by ID
4 | """
5 | character(id: ID!): Character
6 | """
7 | Get the list of all characters
8 | """
9 | characters(page: Int, filter: FilterCharacter): Characters
10 | """
11 | Get a list of characters selected by ids
12 | """
13 | charactersByIds(ids: [ID!]!): [Character]
14 | """
15 | Get a specific locations by ID
16 | """
17 | location(id: ID!): Location
18 | """
19 | Get the list of all locations
20 | """
21 | locations(page: Int, filter: FilterLocation): Locations
22 | """
23 | Get a list of locations selected by ids
24 | """
25 | locationsByIds(ids: [ID!]!): [Location]
26 | """
27 | Get a specific episode by ID
28 | """
29 | episode(id: ID!): Episode
30 | """
31 | Get the list of all episodes
32 | """
33 | episodes(page: Int, filter: FilterEpisode): Episodes
34 | """
35 | Get a list of episodes selected by ids
36 | """
37 | episodesByIds(ids: [ID!]!): [Episode]
38 | }
39 |
40 | type Character {
41 | """
42 | The id of the character.
43 | """
44 | id: ID
45 | """
46 | The name of the character.
47 | """
48 | name: String
49 | """
50 | The status of the character ('Alive', 'Dead' or 'unknown').
51 | """
52 | status: String
53 | """
54 | The species of the character.
55 | """
56 | species: String
57 | """
58 | The type or subspecies of the character.
59 | """
60 | type: String
61 | """
62 | The gender of the character ('Female', 'Male', 'Genderless' or 'unknown').
63 | """
64 | gender: String
65 | """
66 | The character's origin location
67 | """
68 | origin: Location
69 | """
70 | The character's last known location
71 | """
72 | location: Location
73 | """
74 | Link to the character's image.
75 | All images are 300x300px and most are medium shots or portraits since they are intended to be used as avatars.
76 | """
77 | image: String
78 | """
79 | Episodes in which this character appeared.
80 | """
81 | episode: [Episode]
82 | """
83 | Time at which the character was created in the database.
84 | """
85 | created: String
86 | }
87 |
88 | type Location {
89 | """
90 | The id of the location.
91 | """
92 | id: ID
93 | """
94 | The name of the location.
95 | """
96 | name: String
97 | """
98 | The type of the location.
99 | """
100 | type: String
101 | """
102 | The dimension in which the location is located.
103 | """
104 | dimension: String
105 | """
106 | List of characters who have been last seen in the location.
107 | """
108 | residents: [Character]
109 | """
110 | Time at which the location was created in the database.
111 | """
112 | created: String
113 | }
114 |
115 | type Episode {
116 | """
117 | The id of the episode.
118 | """
119 | id: ID
120 | """
121 | The name of the episode.
122 | """
123 | name: String
124 | """
125 | The air date of the episode.
126 | """
127 | air_date: String
128 | """
129 | The code of the episode.
130 | """
131 | episode: String
132 | """
133 | List of characters who have been seen in the episode.
134 | """
135 | characters: [Character]
136 | """
137 | Time at which the episode was created in the database.
138 | """
139 | created: String
140 | }
141 |
142 | input FilterCharacter {
143 | name: String
144 | status: String
145 | species: String
146 | type: String
147 | gender: String
148 | }
149 |
150 | type Characters {
151 | info: Info
152 | results: [Character]
153 | }
154 |
155 | type Info {
156 | """
157 | The length of the response.
158 | """
159 | count: Int
160 | """
161 | The amount of pages.
162 | """
163 | pages: Int
164 | """
165 | Number of the next page (if it exists)
166 | """
167 | next: Int
168 | """
169 | Number of the previous page (if it exists)
170 | """
171 | prev: Int
172 | }
173 |
174 | input FilterLocation {
175 | name: String
176 | type: String
177 | dimension: String
178 | }
179 |
180 | type Locations {
181 | info: Info
182 | results: [Location]
183 | }
184 |
185 | input FilterEpisode {
186 | name: String
187 | episode: String
188 | }
189 |
190 | type Episodes {
191 | info: Info
192 | results: [Episode]
193 | }
194 |
195 | enum CacheControlScope {
196 | PUBLIC
197 | PRIVATE
198 | }
199 |
200 | """
201 | The `Upload` scalar type represents a file upload.
202 | """
203 | scalar Upload
204 |
205 | schema {
206 | query: Query
207 | }
208 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | kotlin = "2.2.0"
3 | ksp = "2.2.0-2.0.2"
4 | kotlinx-coroutines = "1.10.2"
5 |
6 | androidGradlePlugin = "8.12.0"
7 | koin = "4.1.0"
8 | koinCompose = "4.1.0"
9 | apollo = "4.3.2"
10 | kmpNativeCoroutines = "1.0.0-ALPHA-45"
11 | kmpObservableViewModel = "1.0.0-BETA-12"
12 |
13 | androidxActivity = "1.10.1"
14 | androidxComposeBom = "2025.07.00"
15 | androidxPaging = "3.3.6"
16 | androidxNavigationCompose = "2.9.3"
17 | accompanist = "0.30.1"
18 | coilCompose = "2.7.0"
19 |
20 | junit = "4.13.2"
21 |
22 | minSdk = "24"
23 | targetSdk = "36"
24 | compileSdk = "36"
25 |
26 |
27 |
28 | [libraries]
29 | kotlinx-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
30 | kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
31 |
32 | androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" }
33 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" }
34 | androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
35 | androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" }
36 | androidx-compose-material = { group = "androidx.compose.material", name = "material" }
37 | androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" }
38 | androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
39 | androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" }
40 | androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
41 | androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
42 | androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test" }
43 | androidx-compose-ui-test-junit = { group = "androidx.compose.ui", name = "ui-test-junit4" }
44 | androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
45 | androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
46 | androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
47 |
48 | androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "androidxPaging" }
49 | androidx-paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "androidxPaging" }
50 | androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigationCompose" }
51 | accompanist-insets = { group = "com.google.accompanist", name = "accompanist-insets", version.ref = "accompanist" }
52 | coilCompose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" }
53 |
54 | koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
55 | koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koinCompose" }
56 | koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
57 | koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }
58 |
59 | apollo-runtime = { group = "com.apollographql.apollo", name = "apollo-runtime", version.ref = "apollo" }
60 | apollo-normalized-cache = { group = "com.apollographql.apollo", name = "apollo-normalized-cache", version.ref = "apollo" }
61 | apollo-normalized-cache-sqlite = { group = "com.apollographql.apollo", name = "apollo-normalized-cache-sqlite", version.ref = "apollo" }
62 | apollo-mockserver = { group = "com.apollographql.apollo", name = "apollo-mockserver", version.ref = "apollo" }
63 | apollo-testing-support = { group = "com.apollographql.apollo", name = "apollo-testing-support", version.ref = "apollo" }
64 |
65 | kmpObservableViewModel = { module = "com.rickclephas.kmp:kmp-observableviewmodel-core", version.ref = "kmpObservableViewModel" }
66 |
67 | junit = { module = "junit:junit", version.ref = "junit" }
68 |
69 |
70 | [plugins]
71 | android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
72 | android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
73 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
74 | kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
75 | apollo = { id = "com.apollographql.apollo", version.ref = "apollo" }
76 | kmpNativeCoroutines = { id = "com.rickclephas.kmp.nativecoroutines", version.ref = "kmpNativeCoroutines" }
77 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
--------------------------------------------------------------------------------
/androidApp/src/main/java/dev/johnoreilly/mortycomposekmm/ui/episodes/EpisodeDetailView.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.ui.episodes
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.foundation.lazy.LazyColumn
5 | import androidx.compose.foundation.shape.CircleShape
6 | import androidx.compose.material3.*
7 | import androidx.compose.runtime.*
8 | import androidx.compose.ui.Alignment
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.draw.clip
11 | import androidx.compose.ui.layout.ContentScale
12 | import androidx.compose.ui.text.font.FontWeight
13 | import androidx.compose.ui.unit.dp
14 | import coil.compose.AsyncImage
15 | import dev.johnoreilly.mortycomposekmm.fragment.EpisodeDetail
16 | import dev.johnoreilly.mortycomposekmm.shared.viewmodel.EpisodesViewModel
17 | import dev.johnoreilly.mortycomposekmm.ui.components.MortyDetailTopAppBar
18 | import dev.johnoreilly.mortycomposekmm.ui.components.PortalLoadingAnimation
19 | import org.koin.compose.koinInject
20 |
21 | @Composable
22 | fun EpisodeDetailView(episodeId: String, popBack: () -> Unit, updateTitle: (String) -> Unit) {
23 | val viewModel: EpisodesViewModel = koinInject()
24 | val (episode, setEpisode) = remember { mutableStateOf(null) }
25 |
26 | LaunchedEffect(episodeId) {
27 | setEpisode(viewModel.getEpisode(episodeId))
28 | }
29 |
30 | // Update the shared title when episode is loaded
31 | LaunchedEffect(episode) {
32 | episode?.let {
33 | updateTitle(it.name)
34 | }
35 | }
36 |
37 | Box(
38 | modifier = Modifier.fillMaxSize()
39 | ) {
40 | episode?.let { ep ->
41 | LazyColumn(
42 | modifier = Modifier
43 | .fillMaxSize()
44 | .padding(16.dp)
45 | ) {
46 | // Episode header
47 | item {
48 | EpisodeHeader(ep)
49 | }
50 |
51 | // Characters section
52 | item {
53 | Card(
54 | modifier = Modifier
55 | .fillMaxWidth()
56 | .padding(vertical = 16.dp),
57 | shape = MaterialTheme.shapes.medium,
58 | elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
59 | colors = CardDefaults.cardColors(
60 | containerColor = MaterialTheme.colorScheme.surface
61 | )
62 | ) {
63 | Column(
64 | modifier = Modifier.padding(16.dp)
65 | ) {
66 | Text(
67 | text = "Characters",
68 | style = MaterialTheme.typography.titleLarge,
69 | fontWeight = FontWeight.Bold,
70 | color = MaterialTheme.colorScheme.primary,
71 | modifier = Modifier.padding(bottom = 16.dp)
72 | )
73 |
74 | EpisodeCharactersList(ep)
75 | }
76 | }
77 | }
78 | }
79 | } ?: run {
80 | // Loading state
81 | Box(
82 | modifier = Modifier
83 | .fillMaxSize(),
84 | contentAlignment = Alignment.Center
85 | ) {
86 | PortalLoadingAnimation()
87 | }
88 | }
89 | }
90 | }
91 |
92 | @Composable
93 | private fun EpisodeHeader(episode: EpisodeDetail) {
94 | Card(
95 | modifier = Modifier.fillMaxWidth(),
96 | shape = MaterialTheme.shapes.medium,
97 | elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
98 | colors = CardDefaults.cardColors(
99 | containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f)
100 | )
101 | ) {
102 | Column(
103 | modifier = Modifier.padding(16.dp)
104 | ) {
105 | Text(
106 | text = episode.name,
107 | style = MaterialTheme.typography.headlineMedium,
108 | fontWeight = FontWeight.Bold,
109 | color = MaterialTheme.colorScheme.onPrimaryContainer
110 | )
111 |
112 | Spacer(modifier = Modifier.height(8.dp))
113 |
114 | Text(
115 | text = "Episode: ${episode.episode}",
116 | style = MaterialTheme.typography.bodyLarge,
117 | color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
118 | )
119 |
120 | Spacer(modifier = Modifier.height(4.dp))
121 |
122 | Text(
123 | text = "Air Date: ${episode.air_date}",
124 | style = MaterialTheme.typography.bodyMedium,
125 | color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
126 | )
127 | }
128 | }
129 | }
130 |
131 | @Composable
132 | private fun EpisodeCharactersList(episode: EpisodeDetail) {
133 | Column {
134 | episode.characters.filterNotNull().forEach { character ->
135 | Row(
136 | modifier = Modifier
137 | .fillMaxWidth()
138 | .padding(vertical = 8.dp),
139 | verticalAlignment = Alignment.CenterVertically
140 | ) {
141 | // Character image
142 | AsyncImage(
143 | model = character.image,
144 | contentDescription = character.name,
145 | contentScale = ContentScale.Crop,
146 | modifier = Modifier
147 | .size(40.dp)
148 | .clip(CircleShape)
149 | )
150 |
151 | // Character name
152 | Text(
153 | text = character.name,
154 | style = MaterialTheme.typography.bodyLarge,
155 | fontWeight = FontWeight.Medium,
156 | modifier = Modifier.padding(start = 16.dp)
157 | )
158 | }
159 | Divider(
160 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
161 | modifier = Modifier.padding(vertical = 4.dp)
162 | )
163 | }
164 | }
165 | }
--------------------------------------------------------------------------------
/androidApp/src/main/java/dev/johnoreilly/mortycomposekmm/ui/locations/LocationDetailView.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.ui.locations
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.foundation.lazy.LazyColumn
5 | import androidx.compose.foundation.shape.CircleShape
6 | import androidx.compose.material3.*
7 | import androidx.compose.runtime.*
8 | import androidx.compose.ui.Alignment
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.draw.clip
11 | import androidx.compose.ui.layout.ContentScale
12 | import androidx.compose.ui.text.font.FontWeight
13 | import androidx.compose.ui.unit.dp
14 | import coil.compose.AsyncImage
15 | import dev.johnoreilly.mortycomposekmm.fragment.LocationDetail
16 | import dev.johnoreilly.mortycomposekmm.shared.viewmodel.LocationsViewModel
17 | import dev.johnoreilly.mortycomposekmm.ui.components.MortyDetailTopAppBar
18 | import dev.johnoreilly.mortycomposekmm.ui.components.PortalLoadingAnimation
19 | import org.koin.compose.koinInject
20 |
21 | @Composable
22 | fun LocationDetailView(locationId: String, popBack: () -> Unit, updateTitle: (String) -> Unit) {
23 | val viewModel: LocationsViewModel = koinInject()
24 | val (location, setLocation) = remember { mutableStateOf(null) }
25 |
26 | LaunchedEffect(locationId) {
27 | setLocation(viewModel.getLocation(locationId))
28 | }
29 |
30 | // Update the shared title when location is loaded
31 | LaunchedEffect(location) {
32 | location?.let {
33 | updateTitle(it.name)
34 | }
35 | }
36 |
37 | Box(
38 | modifier = Modifier.fillMaxSize()
39 | ) {
40 | location?.let { loc ->
41 | LazyColumn(
42 | modifier = Modifier
43 | .fillMaxSize()
44 | .padding(16.dp)
45 | ) {
46 | // Location header
47 | item {
48 | LocationHeader(loc)
49 | }
50 |
51 | // Residents section
52 | item {
53 | Card(
54 | modifier = Modifier
55 | .fillMaxWidth()
56 | .padding(vertical = 16.dp),
57 | shape = MaterialTheme.shapes.medium,
58 | elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
59 | colors = CardDefaults.cardColors(
60 | containerColor = MaterialTheme.colorScheme.surface
61 | )
62 | ) {
63 | Column(
64 | modifier = Modifier.padding(16.dp)
65 | ) {
66 | Text(
67 | text = "Residents",
68 | style = MaterialTheme.typography.titleLarge,
69 | fontWeight = FontWeight.Bold,
70 | color = MaterialTheme.colorScheme.primary,
71 | modifier = Modifier.padding(bottom = 16.dp)
72 | )
73 |
74 | LocationResidentList(loc)
75 | }
76 | }
77 | }
78 | }
79 | } ?: run {
80 | // Loading state
81 | Box(
82 | modifier = Modifier
83 | .fillMaxSize(),
84 | contentAlignment = Alignment.Center
85 | ) {
86 | PortalLoadingAnimation()
87 | }
88 | }
89 | }
90 | }
91 |
92 | @Composable
93 | private fun LocationHeader(location: LocationDetail) {
94 | Card(
95 | modifier = Modifier.fillMaxWidth(),
96 | shape = MaterialTheme.shapes.medium,
97 | elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
98 | colors = CardDefaults.cardColors(
99 | containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.7f)
100 | )
101 | ) {
102 | Column(
103 | modifier = Modifier.padding(16.dp)
104 | ) {
105 | Text(
106 | text = location.name,
107 | style = MaterialTheme.typography.headlineMedium,
108 | fontWeight = FontWeight.Bold,
109 | color = MaterialTheme.colorScheme.onTertiaryContainer
110 | )
111 |
112 | Spacer(modifier = Modifier.height(8.dp))
113 |
114 | Text(
115 | text = "Type: ${location.type}",
116 | style = MaterialTheme.typography.bodyLarge,
117 | color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f)
118 | )
119 |
120 | Spacer(modifier = Modifier.height(4.dp))
121 |
122 | Text(
123 | text = "Dimension: ${location.dimension}",
124 | style = MaterialTheme.typography.bodyMedium,
125 | color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.7f)
126 | )
127 | }
128 | }
129 | }
130 |
131 | @Composable
132 | private fun LocationResidentList(location: LocationDetail) {
133 | Column {
134 | location.residents.filterNotNull().forEach { resident ->
135 | Row(
136 | modifier = Modifier
137 | .fillMaxWidth()
138 | .padding(vertical = 8.dp),
139 | verticalAlignment = Alignment.CenterVertically
140 | ) {
141 | // Resident image
142 | AsyncImage(
143 | model = resident.image,
144 | contentDescription = resident.name,
145 | contentScale = ContentScale.Crop,
146 | modifier = Modifier
147 | .size(40.dp)
148 | .clip(CircleShape)
149 | )
150 |
151 | // Resident name
152 | Text(
153 | text = resident.name,
154 | style = MaterialTheme.typography.bodyLarge,
155 | fontWeight = FontWeight.Medium,
156 | modifier = Modifier.padding(start = 16.dp)
157 | )
158 | }
159 | Divider(
160 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
161 | modifier = Modifier.padding(vertical = 4.dp)
162 | )
163 | }
164 | }
165 | }
--------------------------------------------------------------------------------
/androidApp/src/main/java/dev/johnoreilly/mortycomposekmm/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.ui.theme
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.foundation.shape.RoundedCornerShape
5 | import androidx.compose.material3.ColorScheme
6 | import androidx.compose.material3.MaterialTheme
7 | import androidx.compose.material3.Shapes
8 | import androidx.compose.material3.Typography
9 | import androidx.compose.material3.darkColorScheme
10 | import androidx.compose.material3.lightColorScheme
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.ui.graphics.Color
13 | import androidx.compose.ui.text.TextStyle
14 | import androidx.compose.ui.text.font.FontFamily
15 | import androidx.compose.ui.text.font.FontWeight
16 | import androidx.compose.ui.unit.dp
17 | import androidx.compose.ui.unit.sp
18 |
19 | // Custom colors for Rick and Morty theme
20 | val RickBlue = Color(0xFF97CE4C)
21 | val MortyYellow = Color(0xFFF0E14A)
22 | val PortalGreen = Color(0xFF00B5CC)
23 | val SpaceBlack = Color(0xFF202428)
24 | val AlienGreen = Color(0xFF97CE4C)
25 | val StatusAlive = Color(0xFF55CC44)
26 | val StatusDead = Color(0xFFD63D2E)
27 | val StatusUnknown = Color(0xFF9E9E9E)
28 |
29 | // Additional Rick and Morty themed colors
30 | val PortalSwirl = Color(0xFF39FF14) // Bright portal green
31 | val GarageWall = Color(0xFFE0E0E0) // Rick's garage wall color
32 | val MeeseeksBlue = Color(0xFF00B3E3) // Mr. Meeseeks blue
33 | val SchwiftyRed = Color(0xFFFF5252) // Vibrant red
34 | val PlumbusPink = Color(0xFFFFAEC9) // Plumbus pink
35 | val DimensionPurple = Color(0xFF9C27B0) // Interdimensional purple
36 |
37 | // Custom Typography for Rick and Morty theme
38 | val MortyTypography = Typography(
39 | // Large titles for main screens
40 | headlineLarge = TextStyle(
41 | fontWeight = FontWeight.ExtraBold,
42 | fontSize = 32.sp,
43 | letterSpacing = (-0.5).sp
44 | ),
45 | // Character names
46 | headlineMedium = TextStyle(
47 | fontWeight = FontWeight.Bold,
48 | fontSize = 28.sp,
49 | letterSpacing = 0.sp
50 | ),
51 | // Section titles
52 | titleLarge = TextStyle(
53 | fontWeight = FontWeight.Bold,
54 | fontSize = 22.sp,
55 | letterSpacing = 0.sp
56 | ),
57 | // Card titles
58 | titleMedium = TextStyle(
59 | fontWeight = FontWeight.SemiBold,
60 | fontSize = 18.sp,
61 | letterSpacing = 0.15.sp
62 | ),
63 | // Regular body text
64 | bodyLarge = TextStyle(
65 | fontWeight = FontWeight.Normal,
66 | fontSize = 16.sp,
67 | letterSpacing = 0.15.sp
68 | ),
69 | // Secondary information
70 | bodyMedium = TextStyle(
71 | fontWeight = FontWeight.Medium,
72 | fontSize = 14.sp,
73 | letterSpacing = 0.25.sp
74 | ),
75 | // Small details like episode air dates
76 | bodySmall = TextStyle(
77 | fontWeight = FontWeight.Normal,
78 | fontSize = 12.sp,
79 | letterSpacing = 0.4.sp
80 | ),
81 | // Labels and buttons
82 | labelMedium = TextStyle(
83 | fontWeight = FontWeight.Medium,
84 | fontSize = 14.sp,
85 | letterSpacing = 1.25.sp
86 | )
87 | )
88 |
89 | // Custom Shapes for Rick and Morty theme
90 | val MortyShapes = Shapes(
91 | // Small components like chips, small buttons
92 | small = RoundedCornerShape(
93 | topStart = 4.dp,
94 | topEnd = 12.dp,
95 | bottomStart = 12.dp,
96 | bottomEnd = 4.dp
97 | ),
98 | // Medium components like cards, dialogs
99 | medium = RoundedCornerShape(
100 | topStart = 8.dp,
101 | topEnd = 16.dp,
102 | bottomStart = 16.dp,
103 | bottomEnd = 8.dp
104 | ),
105 | // Large components like bottom sheets, expanded cards
106 | large = RoundedCornerShape(
107 | topStart = 16.dp,
108 | topEnd = 24.dp,
109 | bottomStart = 24.dp,
110 | bottomEnd = 16.dp
111 | )
112 | )
113 |
114 | private val LightColorScheme = lightColorScheme(
115 | primary = PortalGreen,
116 | onPrimary = Color.White,
117 | primaryContainer = AlienGreen,
118 | onPrimaryContainer = Color.White,
119 | secondary = MortyYellow,
120 | onSecondary = SpaceBlack,
121 | secondaryContainer = MortyYellow.copy(alpha = 0.7f),
122 | onSecondaryContainer = SpaceBlack,
123 | tertiary = MeeseeksBlue,
124 | onTertiary = Color.White,
125 | tertiaryContainer = MeeseeksBlue.copy(alpha = 0.7f),
126 | onTertiaryContainer = Color.White,
127 | error = SchwiftyRed,
128 | errorContainer = SchwiftyRed.copy(alpha = 0.7f),
129 | background = GarageWall,
130 | onBackground = SpaceBlack,
131 | surface = Color.White,
132 | onSurface = SpaceBlack,
133 | surfaceVariant = Color(0xFFF0F0F0),
134 | onSurfaceVariant = SpaceBlack.copy(alpha = 0.7f),
135 | outline = PortalGreen.copy(alpha = 0.5f)
136 | )
137 |
138 | private val DarkColorScheme = darkColorScheme(
139 | primary = PortalSwirl,
140 | onPrimary = SpaceBlack,
141 | primaryContainer = PortalGreen,
142 | onPrimaryContainer = Color.White,
143 | secondary = MortyYellow,
144 | onSecondary = SpaceBlack,
145 | secondaryContainer = MortyYellow.copy(alpha = 0.5f),
146 | onSecondaryContainer = Color.White,
147 | tertiary = MeeseeksBlue,
148 | onTertiary = Color.White,
149 | tertiaryContainer = MeeseeksBlue.copy(alpha = 0.5f),
150 | onTertiaryContainer = Color.White,
151 | error = SchwiftyRed,
152 | errorContainer = SchwiftyRed.copy(alpha = 0.5f),
153 | background = SpaceBlack,
154 | onBackground = Color.White,
155 | surface = Color(0xFF202020),
156 | onSurface = Color.White,
157 | surfaceVariant = Color(0xFF303030),
158 | onSurfaceVariant = Color.White.copy(alpha = 0.7f),
159 | outline = PortalSwirl.copy(alpha = 0.5f)
160 | )
161 |
162 | @Composable
163 | fun MortyComposeTheme(
164 | darkTheme: Boolean = isSystemInDarkTheme(),
165 | content: @Composable () -> Unit
166 | ) {
167 | val colorScheme = if (darkTheme) {
168 | DarkColorScheme
169 | } else {
170 | LightColorScheme
171 | }
172 |
173 | MaterialTheme(
174 | colorScheme = colorScheme,
175 | typography = MortyTypography,
176 | shapes = MortyShapes,
177 | content = content
178 | )
179 | }
180 |
181 | // Extension function to get status color based on character status
182 | fun ColorScheme.getStatusColor(status: String): Color {
183 | return when (status.lowercase()) {
184 | "alive" -> StatusAlive
185 | "dead" -> StatusDead
186 | else -> StatusUnknown
187 | }
188 | }
--------------------------------------------------------------------------------
/androidApp/src/main/java/dev/johnoreilly/mortycomposekmm/ui/components/ErrorScreens.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.ui.components
2 |
3 | import androidx.compose.animation.core.*
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.layout.*
6 | import androidx.compose.foundation.shape.CircleShape
7 | import androidx.compose.foundation.shape.RoundedCornerShape
8 | import androidx.compose.material.icons.Icons
9 | import androidx.compose.material.icons.filled.Refresh
10 | import androidx.compose.material3.*
11 | import androidx.compose.runtime.*
12 | import androidx.compose.ui.Alignment
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.draw.clip
15 | import androidx.compose.ui.draw.rotate
16 | import androidx.compose.ui.graphics.Brush
17 | import androidx.compose.ui.graphics.Color
18 | import androidx.compose.ui.text.font.FontWeight
19 | import androidx.compose.ui.text.style.TextAlign
20 | import androidx.compose.ui.unit.dp
21 | import dev.johnoreilly.mortycomposekmm.ui.theme.SchwiftyRed
22 |
23 | /**
24 | * A portal-themed error screen with retry functionality
25 | */
26 | @Composable
27 | fun PortalErrorScreen(
28 | message: String = "Something went wrong",
29 | onRetry: () -> Unit
30 | ) {
31 | Column(
32 | modifier = Modifier
33 | .fillMaxWidth()
34 | .padding(16.dp),
35 | horizontalAlignment = Alignment.CenterHorizontally,
36 | verticalArrangement = Arrangement.Center
37 | ) {
38 | // Error portal animation
39 | ErrorPortalAnimation()
40 |
41 | Spacer(modifier = Modifier.height(24.dp))
42 |
43 | // Error message
44 | Text(
45 | text = message,
46 | style = MaterialTheme.typography.titleMedium,
47 | fontWeight = FontWeight.Bold,
48 | color = MaterialTheme.colorScheme.error,
49 | textAlign = TextAlign.Center
50 | )
51 |
52 | Spacer(modifier = Modifier.height(16.dp))
53 |
54 | // Retry button with portal-themed styling
55 | Button(
56 | onClick = onRetry,
57 | colors = ButtonDefaults.buttonColors(
58 | containerColor = MaterialTheme.colorScheme.primaryContainer,
59 | contentColor = MaterialTheme.colorScheme.onPrimaryContainer
60 | ),
61 | shape = MaterialTheme.shapes.medium
62 | ) {
63 | Icon(
64 | imageVector = Icons.Default.Refresh,
65 | contentDescription = "Retry",
66 | modifier = Modifier.size(20.dp)
67 | )
68 | Spacer(modifier = Modifier.width(8.dp))
69 | Text(
70 | text = "Try Again",
71 | style = MaterialTheme.typography.labelLarge,
72 | fontWeight = FontWeight.Bold
73 | )
74 | }
75 | }
76 | }
77 |
78 | /**
79 | * A smaller error indicator for use in lists
80 | */
81 | @Composable
82 | fun SmallErrorIndicator(
83 | message: String = "Failed to load",
84 | onRetry: () -> Unit
85 | ) {
86 | Row(
87 | modifier = Modifier
88 | .fillMaxWidth()
89 | .padding(16.dp),
90 | verticalAlignment = Alignment.CenterVertically,
91 | horizontalArrangement = Arrangement.SpaceBetween
92 | ) {
93 | // Error message
94 | Text(
95 | text = message,
96 | style = MaterialTheme.typography.bodyMedium,
97 | color = MaterialTheme.colorScheme.error,
98 | modifier = Modifier.weight(1f)
99 | )
100 |
101 | // Small retry button
102 | IconButton(
103 | onClick = onRetry,
104 | modifier = Modifier
105 | .clip(CircleShape)
106 | .background(MaterialTheme.colorScheme.primaryContainer)
107 | .size(36.dp)
108 | ) {
109 | Icon(
110 | imageVector = Icons.Default.Refresh,
111 | contentDescription = "Retry",
112 | tint = MaterialTheme.colorScheme.onPrimaryContainer,
113 | modifier = Modifier.size(20.dp)
114 | )
115 | }
116 | }
117 | }
118 |
119 | /**
120 | * A portal-themed error animation
121 | */
122 | @Composable
123 | private fun ErrorPortalAnimation() {
124 | // Create animations
125 | val infiniteTransition = rememberInfiniteTransition()
126 |
127 | // Rotation animation
128 | val rotation by infiniteTransition.animateFloat(
129 | initialValue = 0f,
130 | targetValue = 360f,
131 | animationSpec = infiniteRepeatable(
132 | animation = tween(3000, easing = LinearEasing)
133 | )
134 | )
135 |
136 | // Pulsating animation
137 | val scale by infiniteTransition.animateFloat(
138 | initialValue = 0.8f,
139 | targetValue = 1.2f,
140 | animationSpec = infiniteRepeatable(
141 | animation = tween(1000, easing = FastOutSlowInEasing),
142 | repeatMode = RepeatMode.Reverse
143 | )
144 | )
145 |
146 | Box(
147 | modifier = Modifier
148 | .size(120.dp)
149 | .rotate(rotation),
150 | contentAlignment = Alignment.Center
151 | ) {
152 | // Outer error portal
153 | Box(
154 | modifier = Modifier
155 | .size(100.dp)
156 | .clip(CircleShape)
157 | .background(
158 | brush = Brush.radialGradient(
159 | colors = listOf(
160 | SchwiftyRed.copy(alpha = 0.7f),
161 | SchwiftyRed.copy(alpha = 0.3f)
162 | )
163 | )
164 | )
165 | )
166 |
167 | // Inner error portal with X shape
168 | Box(
169 | modifier = Modifier
170 | .size(60.dp)
171 | .clip(CircleShape)
172 | .background(
173 | brush = Brush.radialGradient(
174 | colors = listOf(
175 | SchwiftyRed,
176 | SchwiftyRed.copy(alpha = 0.5f)
177 | )
178 | )
179 | ),
180 | contentAlignment = Alignment.Center
181 | ) {
182 | // X mark
183 | Box(
184 | modifier = Modifier
185 | .size(30.dp)
186 | .rotate(45f)
187 | .background(Color.White)
188 | .width(6.dp)
189 | )
190 | Box(
191 | modifier = Modifier
192 | .size(30.dp)
193 | .rotate(-45f)
194 | .background(Color.White)
195 | .width(6.dp)
196 | )
197 | }
198 | }
199 | }
--------------------------------------------------------------------------------
/androidApp/src/main/java/dev/johnoreilly/mortycomposekmm/ui/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.ui
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.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.material.icons.Icons
10 | import androidx.compose.material.icons.filled.LocationOn
11 | import androidx.compose.material.icons.filled.Person
12 | import androidx.compose.material.icons.filled.Tv
13 | import androidx.compose.material3.*
14 | import androidx.compose.runtime.*
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.graphics.vector.ImageVector
17 | import androidx.compose.ui.unit.dp
18 | import androidx.navigation.NavHostController
19 | import androidx.navigation.compose.*
20 | import dev.johnoreilly.mortycomposekmm.ui.characters.CharacterDetailView
21 | import dev.johnoreilly.mortycomposekmm.ui.characters.CharactersListView
22 | import dev.johnoreilly.mortycomposekmm.ui.components.MortyTopAppBar
23 | import dev.johnoreilly.mortycomposekmm.ui.components.MortyDetailTopAppBar
24 | import dev.johnoreilly.mortycomposekmm.ui.episodes.EpisodeDetailView
25 | import dev.johnoreilly.mortycomposekmm.ui.episodes.EpisodesListView
26 | import dev.johnoreilly.mortycomposekmm.ui.locations.LocationDetailView
27 | import dev.johnoreilly.mortycomposekmm.ui.locations.LocationsListView
28 | import dev.johnoreilly.mortycomposekmm.ui.theme.MortyComposeTheme
29 |
30 |
31 | sealed class Screens(val route: String, val label: String, val icon: ImageVector? = null) {
32 | data object CharactersScreen : Screens("Characters", "Characters", Icons.Default.Person)
33 | data object EpisodesScreen : Screens("Episodes", "Episodes", Icons.Default.Tv)
34 | data object LocationsScreen : Screens("Locations", "Locations", Icons.Default.LocationOn)
35 | data object CharacterDetailsScreen : Screens("CharacterDetails", "CharacterDetails")
36 | data object EpisodeDetailsScreen : Screens("EpisodeDetails", "EpisodeDetails")
37 | data object LocationDetailsScreen : Screens("LocationDetails", "LocationDetails")
38 | }
39 |
40 | class MainActivity : ComponentActivity() {
41 |
42 | override fun onCreate(savedInstanceState: Bundle?) {
43 | super.onCreate(savedInstanceState)
44 |
45 | enableEdgeToEdge()
46 | setContent {
47 | MortyComposeTheme {
48 | MainLayout()
49 | }
50 | }
51 | }
52 | }
53 |
54 | @OptIn(ExperimentalMaterial3Api::class)
55 | @Composable
56 | fun MainLayout() {
57 | val navController = rememberNavController()
58 | val currentRoute = currentRoute(navController)
59 |
60 | // State to hold the detail screen title
61 | var detailTitle by remember { mutableStateOf("") }
62 |
63 | // Function to update the detail title (will be passed to detail screens)
64 | val updateDetailTitle: (String) -> Unit = { title ->
65 | detailTitle = title
66 | }
67 |
68 | // Determine if we're on a detail screen
69 | val isDetailScreen = remember(currentRoute) {
70 | currentRoute?.startsWith(Screens.CharacterDetailsScreen.route) == true ||
71 | currentRoute?.startsWith(Screens.EpisodeDetailsScreen.route) == true ||
72 | currentRoute?.startsWith(Screens.LocationDetailsScreen.route) == true
73 | }
74 |
75 | // Get current main screen based on route
76 | val currentMainScreen = remember(currentRoute) {
77 | when (currentRoute) {
78 | Screens.CharactersScreen.route -> Screens.CharactersScreen
79 | Screens.EpisodesScreen.route -> Screens.EpisodesScreen
80 | Screens.LocationsScreen.route -> Screens.LocationsScreen
81 | else -> Screens.CharactersScreen
82 | }
83 | }
84 |
85 | val bottomNavigationItems = listOf(Screens.CharactersScreen, Screens.EpisodesScreen, Screens.LocationsScreen)
86 |
87 | Scaffold(
88 | // Use our shared MortyTopAppBar component with dynamic content
89 | topBar = {
90 | if (isDetailScreen) {
91 | // Use detail app bar for detail screens
92 | MortyDetailTopAppBar(
93 | title = detailTitle,
94 | onBackClick = { navController.popBackStack() }
95 | )
96 | } else {
97 | // Use regular app bar for main screens
98 | MortyTopAppBar(
99 | title = currentMainScreen.label
100 | )
101 | }
102 | },
103 | // Apply navigation bar padding to the bottom navigation
104 | bottomBar = {
105 | Surface(
106 | tonalElevation = 8.dp,
107 | color = MaterialTheme.colorScheme.surface,
108 | ) {
109 | MortyBottomNavigation(navController, bottomNavigationItems)
110 | }
111 | },
112 | containerColor = MaterialTheme.colorScheme.background
113 | ) { paddingValues ->
114 |
115 | NavHost(navController, startDestination = Screens.CharactersScreen.route,
116 | modifier = Modifier.padding(paddingValues).fillMaxSize()) {
117 | composable(Screens.CharactersScreen.route) {
118 | CharactersListView() {
119 | navController.navigate(Screens.CharacterDetailsScreen.route + "/${it.id}")
120 | }
121 | }
122 | composable(Screens.CharacterDetailsScreen.route + "/{id}") { backStackEntry ->
123 | CharacterDetailView(
124 | characterId = backStackEntry.arguments?.getString("id") as String,
125 | popBack = { navController.popBackStack() },
126 | updateTitle = updateDetailTitle
127 | )
128 | }
129 | composable(Screens.EpisodesScreen.route) {
130 | EpisodesListView() {
131 | navController.navigate(Screens.EpisodeDetailsScreen.route + "/${it.id}")
132 | }
133 | }
134 | composable(Screens.EpisodeDetailsScreen.route + "/{id}") { backStackEntry ->
135 | EpisodeDetailView(
136 | episodeId = backStackEntry.arguments?.getString("id") as String,
137 | popBack = { navController.popBackStack() },
138 | updateTitle = updateDetailTitle
139 | )
140 | }
141 | composable(Screens.LocationsScreen.route) {
142 | LocationsListView() {
143 | navController.navigate(Screens.LocationDetailsScreen.route + "/${it.id}")
144 | }
145 | }
146 | composable(Screens.LocationDetailsScreen.route + "/{id}") { backStackEntry ->
147 | LocationDetailView(
148 | locationId = backStackEntry.arguments?.getString("id") as String,
149 | popBack = { navController.popBackStack() },
150 | updateTitle = updateDetailTitle
151 | )
152 | }
153 | }
154 | }
155 | }
156 |
157 |
158 | @Composable
159 | private fun MortyBottomNavigation(
160 | navController: NavHostController,
161 | items: List
162 | ) {
163 | NavigationBar {
164 | val currentRoute = currentRoute(navController)
165 | items.forEach { screen ->
166 | NavigationBarItem(
167 | icon = { screen.icon?.let { Icon(screen.icon, contentDescription = screen.label) } },
168 | label = { Text(screen.label) },
169 | selected = currentRoute == screen.route,
170 | onClick = {
171 | if (currentRoute != screen.route) {
172 | navController.navigate(screen.route) {
173 | popUpTo(navController.graph.startDestinationId)
174 | launchSingleTop = true
175 | }
176 | }
177 | }
178 | )
179 | }
180 | }
181 | }
182 |
183 | @Composable
184 | private fun currentRoute(navController: NavHostController): String? {
185 | val navBackStackEntry by navController.currentBackStackEntryAsState()
186 | return navBackStackEntry?.destination?.route
187 | }
--------------------------------------------------------------------------------
/androidApp/src/main/java/dev/johnoreilly/mortycomposekmm/ui/characters/CharactersListRowView.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.ui.characters
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.border
5 | import androidx.compose.foundation.clickable
6 | import androidx.compose.foundation.interaction.MutableInteractionSource
7 | import androidx.compose.foundation.layout.*
8 | import androidx.compose.foundation.shape.CircleShape
9 | import androidx.compose.foundation.shape.RoundedCornerShape
10 | import androidx.compose.material.ripple.rememberRipple
11 | import androidx.compose.material3.*
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.runtime.remember
14 | import androidx.compose.ui.Alignment
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.draw.clip
17 | import androidx.compose.ui.draw.shadow
18 | import androidx.compose.ui.graphics.Brush
19 | import androidx.compose.ui.graphics.Color
20 | import androidx.compose.ui.layout.ContentScale
21 | import androidx.compose.ui.text.font.FontWeight
22 | import androidx.compose.ui.text.style.TextOverflow
23 | import androidx.compose.ui.unit.dp
24 | import coil.compose.AsyncImage
25 | import dev.johnoreilly.mortycomposekmm.fragment.CharacterDetail
26 | import dev.johnoreilly.mortycomposekmm.ui.theme.getStatusColor
27 |
28 | @Composable
29 | fun CharactersListRowView(character: CharacterDetail, characterSelected: (network: CharacterDetail) -> Unit) {
30 | Card(
31 | modifier = Modifier
32 | .fillMaxWidth()
33 | .padding(horizontal = 16.dp, vertical = 8.dp)
34 | .shadow(
35 | elevation = 6.dp,
36 | shape = MaterialTheme.shapes.medium,
37 | spotColor = dev.johnoreilly.mortycomposekmm.ui.theme.PortalGreen.copy(alpha = 0.5f)
38 | ),
39 | shape = MaterialTheme.shapes.medium,
40 | colors = CardDefaults.cardColors(
41 | containerColor = MaterialTheme.colorScheme.surface
42 | ),
43 | onClick = { characterSelected(character) }
44 | ) {
45 | // Card content with portal-themed background gradient
46 | Box(
47 | modifier = Modifier
48 | .fillMaxWidth()
49 | .background(
50 | brush = Brush.horizontalGradient(
51 | colors = listOf(
52 | MaterialTheme.colorScheme.surface,
53 | MaterialTheme.colorScheme.surface,
54 | MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
55 | )
56 | )
57 | )
58 | ) {
59 | Row(
60 | modifier = Modifier
61 | .fillMaxWidth()
62 | .padding(16.dp),
63 | verticalAlignment = Alignment.CenterVertically
64 | ) {
65 | // Character image with portal-themed border
66 | Box(
67 | modifier = Modifier
68 | .size(80.dp)
69 | .background(
70 | brush = Brush.radialGradient(
71 | colors = listOf(
72 | dev.johnoreilly.mortycomposekmm.ui.theme.PortalGreen.copy(alpha = 0.7f),
73 | dev.johnoreilly.mortycomposekmm.ui.theme.PortalSwirl.copy(alpha = 0.3f)
74 | )
75 | ),
76 | shape = CircleShape
77 | ),
78 | contentAlignment = Alignment.Center
79 | ) {
80 | AsyncImage(
81 | model = character.image,
82 | contentDescription = character.name,
83 | contentScale = ContentScale.Crop,
84 | modifier = Modifier
85 | .size(70.dp)
86 | .clip(CircleShape)
87 | .border(2.dp, MaterialTheme.colorScheme.surface, CircleShape)
88 | )
89 | }
90 |
91 | // Character info
92 | Column(modifier = Modifier.padding(start = 16.dp)) {
93 | // Name
94 | Text(
95 | character.name,
96 | style = MaterialTheme.typography.titleLarge,
97 | fontWeight = FontWeight.Bold,
98 | maxLines = 1,
99 | overflow = TextOverflow.Ellipsis,
100 | color = MaterialTheme.colorScheme.onSurface
101 | )
102 |
103 | // Status with enhanced indicator
104 | val statusColor = MaterialTheme.colorScheme.getStatusColor(character.status)
105 | Card(
106 | modifier = Modifier
107 | .padding(top = 8.dp, bottom = 4.dp),
108 | shape = MaterialTheme.shapes.small,
109 | colors = CardDefaults.cardColors(
110 | containerColor = statusColor.copy(alpha = 0.15f)
111 | ),
112 | elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
113 | ) {
114 | Row(
115 | verticalAlignment = Alignment.CenterVertically,
116 | modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
117 | ) {
118 | Box(
119 | modifier = Modifier
120 | .size(8.dp)
121 | .clip(CircleShape)
122 | .background(statusColor)
123 | )
124 | Text(
125 | text = " ${character.status} - ${character.species}",
126 | style = MaterialTheme.typography.bodySmall,
127 | fontWeight = FontWeight.Medium,
128 | modifier = Modifier.padding(start = 4.dp)
129 | )
130 | }
131 | }
132 |
133 | // Location
134 | Text(
135 | "Last location: ${character.location?.name ?: "Unknown"}",
136 | style = MaterialTheme.typography.bodySmall,
137 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
138 | maxLines = 1,
139 | overflow = TextOverflow.Ellipsis,
140 | modifier = Modifier.padding(top = 4.dp)
141 | )
142 |
143 | // Episodes count with badge
144 | Row(
145 | verticalAlignment = Alignment.CenterVertically,
146 | modifier = Modifier.padding(top = 4.dp)
147 | ) {
148 | Text(
149 | "Episodes:",
150 | style = MaterialTheme.typography.bodySmall,
151 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
152 | )
153 |
154 | Card(
155 | modifier = Modifier.padding(start = 4.dp),
156 | shape = MaterialTheme.shapes.small,
157 | colors = CardDefaults.cardColors(
158 | containerColor = dev.johnoreilly.mortycomposekmm.ui.theme.MeeseeksBlue.copy(alpha = 0.2f)
159 | ),
160 | elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
161 | ) {
162 | Text(
163 | text = "${character.episode.size}",
164 | style = MaterialTheme.typography.bodySmall,
165 | fontWeight = FontWeight.Bold,
166 | color = dev.johnoreilly.mortycomposekmm.ui.theme.MeeseeksBlue,
167 | modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
168 | )
169 | }
170 | }
171 | }
172 | }
173 | }
174 | }
175 | }
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | #
21 | # Gradle start up script for POSIX generated by Gradle.
22 | #
23 | # Important for running:
24 | #
25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
26 | # noncompliant, but you have some other compliant shell such as ksh or
27 | # bash, then to run this script, type that shell name before the whole
28 | # command line, like:
29 | #
30 | # ksh Gradle
31 | #
32 | # Busybox and similar reduced shells will NOT work, because this script
33 | # requires all of these POSIX shell features:
34 | # * functions;
35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
37 | # * compound commands having a testable exit status, especially «case»;
38 | # * various built-in commands including «command», «set», and «ulimit».
39 | #
40 | # Important for patching:
41 | #
42 | # (2) This script targets any POSIX shell, so it avoids extensions provided
43 | # by Bash, Ksh, etc; in particular arrays are avoided.
44 | #
45 | # The "traditional" practice of packing multiple parameters into a
46 | # space-separated string is a well documented source of bugs and security
47 | # problems, so this is (mostly) avoided, by progressively accumulating
48 | # options in "$@", and eventually passing that to Java.
49 | #
50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
52 | # see the in-line comments for details.
53 | #
54 | # There are tweaks for specific operating systems such as AIX, CygWin,
55 | # Darwin, MinGW, and NonStop.
56 | #
57 | # (3) This script is generated from the Groovy template
58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
59 | # within the Gradle project.
60 | #
61 | # You can find Gradle at https://github.com/gradle/gradle/.
62 | #
63 | ##############################################################################
64 |
65 | # Attempt to set APP_HOME
66 |
67 | # Resolve links: $0 may be a link
68 | app_path=$0
69 |
70 | # Need this for daisy-chained symlinks.
71 | while
72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
73 | [ -h "$app_path" ]
74 | do
75 | ls=$( ls -ld "$app_path" )
76 | link=${ls#*' -> '}
77 | case $link in #(
78 | /*) app_path=$link ;; #(
79 | *) app_path=$APP_HOME$link ;;
80 | esac
81 | done
82 |
83 | # This is normally unused
84 | # shellcheck disable=SC2034
85 | APP_BASE_NAME=${0##*/}
86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
88 |
89 | # Use the maximum available, or set MAX_FD != -1 to use that value.
90 | MAX_FD=maximum
91 |
92 | warn () {
93 | echo "$*"
94 | } >&2
95 |
96 | die () {
97 | echo
98 | echo "$*"
99 | echo
100 | exit 1
101 | } >&2
102 |
103 | # OS specific support (must be 'true' or 'false').
104 | cygwin=false
105 | msys=false
106 | darwin=false
107 | nonstop=false
108 | case "$( uname )" in #(
109 | CYGWIN* ) cygwin=true ;; #(
110 | Darwin* ) darwin=true ;; #(
111 | MSYS* | MINGW* ) msys=true ;; #(
112 | NONSTOP* ) nonstop=true ;;
113 | esac
114 |
115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
116 |
117 |
118 | # Determine the Java command to use to start the JVM.
119 | if [ -n "$JAVA_HOME" ] ; then
120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
121 | # IBM's JDK on AIX uses strange locations for the executables
122 | JAVACMD=$JAVA_HOME/jre/sh/java
123 | else
124 | JAVACMD=$JAVA_HOME/bin/java
125 | fi
126 | if [ ! -x "$JAVACMD" ] ; then
127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
128 |
129 | Please set the JAVA_HOME variable in your environment to match the
130 | location of your Java installation."
131 | fi
132 | else
133 | JAVACMD=java
134 | if ! command -v java >/dev/null 2>&1
135 | then
136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
137 |
138 | Please set the JAVA_HOME variable in your environment to match the
139 | location of your Java installation."
140 | fi
141 | fi
142 |
143 | # Increase the maximum file descriptors if we can.
144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
145 | case $MAX_FD in #(
146 | max*)
147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
148 | # shellcheck disable=SC2039,SC3045
149 | MAX_FD=$( ulimit -H -n ) ||
150 | warn "Could not query maximum file descriptor limit"
151 | esac
152 | case $MAX_FD in #(
153 | '' | soft) :;; #(
154 | *)
155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
156 | # shellcheck disable=SC2039,SC3045
157 | ulimit -n "$MAX_FD" ||
158 | warn "Could not set maximum file descriptor limit to $MAX_FD"
159 | esac
160 | fi
161 |
162 | # Collect all arguments for the java command, stacking in reverse order:
163 | # * args from the command line
164 | # * the main class name
165 | # * -classpath
166 | # * -D...appname settings
167 | # * --module-path (only if needed)
168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
169 |
170 | # For Cygwin or MSYS, switch paths to Windows format before running java
171 | if "$cygwin" || "$msys" ; then
172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
174 |
175 | JAVACMD=$( cygpath --unix "$JAVACMD" )
176 |
177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
178 | for arg do
179 | if
180 | case $arg in #(
181 | -*) false ;; # don't mess with options #(
182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
183 | [ -e "$t" ] ;; #(
184 | *) false ;;
185 | esac
186 | then
187 | arg=$( cygpath --path --ignore --mixed "$arg" )
188 | fi
189 | # Roll the args list around exactly as many times as the number of
190 | # args, so each arg winds up back in the position where it started, but
191 | # possibly modified.
192 | #
193 | # NB: a `for` loop captures its iteration list before it begins, so
194 | # changing the positional parameters here affects neither the number of
195 | # iterations, nor the values presented in `arg`.
196 | shift # remove old arg
197 | set -- "$@" "$arg" # push replacement arg
198 | done
199 | fi
200 |
201 |
202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
204 |
205 | # Collect all arguments for the java command:
206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
207 | # and any embedded shellness will be escaped.
208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
209 | # treated as '${Hostname}' itself on the command line.
210 |
211 | set -- \
212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
213 | -classpath "$CLASSPATH" \
214 | org.gradle.wrapper.GradleWrapperMain \
215 | "$@"
216 |
217 | # Stop when "xargs" is not available.
218 | if ! command -v xargs >/dev/null 2>&1
219 | then
220 | die "xargs is not available"
221 | fi
222 |
223 | # Use "xargs" to parse quoted args.
224 | #
225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
226 | #
227 | # In Bash we could simply go:
228 | #
229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
230 | # set -- "${ARGS[@]}" "$@"
231 | #
232 | # but POSIX shell has neither arrays nor command substitution, so instead we
233 | # post-process each arg (as a line of input to sed) to backslash-escape any
234 | # character that might be a shell metacharacter, then use eval to reverse
235 | # that process (while maintaining the separation between arguments), and wrap
236 | # the whole thing up as a single "set" statement.
237 | #
238 | # This will of course break if any of these variables contains a newline or
239 | # an unmatched quote.
240 | #
241 |
242 | eval "set -- $(
243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
244 | xargs -n1 |
245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
246 | tr '\n' ' '
247 | )" '"$@"'
248 |
249 | exec "$JAVACMD" "$@"
250 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Features/Characters/CharacterDetailView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import shared
3 |
4 | struct CharacterDetailView: View {
5 | let character: CharacterDetail
6 |
7 | var body: some View {
8 | ScrollView {
9 | VStack(alignment: .leading, spacing: 16) {
10 | // Character header with image and basic info
11 | CharacterHeader(character: character)
12 |
13 | // Character details section
14 | CharacterDetailsSection(character: character)
15 |
16 | // Episodes section
17 | VStack(alignment: .leading, spacing: 16) {
18 | EpisodesSection(episodes: character.episode as! [CharacterDetail.Episode?])
19 | }
20 | .padding(.horizontal)
21 | }
22 | .padding(.vertical)
23 | }
24 | .navigationTitle(character.name)
25 | }
26 | }
27 |
28 | struct CharacterDetailsSection: View {
29 | let character: CharacterDetail
30 |
31 | var body: some View {
32 | VStack(spacing: 16) {
33 | // Information section
34 | InformationSection(character: character)
35 |
36 | // Location section
37 | LocationSection(origin: character.origin.name, location: character.location.name)
38 | }
39 | .padding(.horizontal)
40 | }
41 | }
42 |
43 | struct InformationSection: View {
44 | let character: CharacterDetail
45 |
46 | var body: some View {
47 | CardSection {
48 | SectionTitle(title: "Information", systemName: "person.fill", tint: .blue)
49 |
50 | VStack(spacing: 8) {
51 | InfoRow(label: "Status", value: character.status, highlight: true)
52 | InfoRow(label: "Species", value: character.species)
53 | if !character.type.isEmpty {
54 | InfoRow(label: "Type", value: character.type)
55 | }
56 | InfoRow(label: "Gender", value: character.gender)
57 | }
58 | .padding(.top, 12)
59 | }
60 | .background(Color.blue.opacity(0.1))
61 | }
62 | }
63 |
64 | struct LocationSection: View {
65 | let origin: String
66 | let location: String
67 |
68 | var body: some View {
69 | CardSection {
70 | SectionTitle(title: "Origin & Location", systemName: "location.fill", tint: .green)
71 |
72 | VStack(spacing: 8) {
73 | InfoRow(label: "Origin", value: origin)
74 | InfoRow(label: "Last known location", value: location, highlight: true)
75 | }
76 | .padding(.top, 12)
77 | }
78 | .background(Color.green.opacity(0.1))
79 | }
80 | }
81 |
82 | // MARK: - Helper Components
83 |
84 | struct CharacterHeader: View {
85 | let character: CharacterDetail
86 |
87 | var body: some View {
88 | ZStack {
89 | // Background gradient
90 | HeaderBackground()
91 |
92 | VStack(spacing: 16) {
93 | // Character image with portal-like border
94 | CharacterPortraitView(imageUrl: character.image)
95 |
96 | // Character name
97 | Text(character.name)
98 | .font(.system(size: 24, weight: .bold))
99 | .multilineTextAlignment(.center)
100 | .lineLimit(2)
101 |
102 | // Status indicator
103 | StatusIndicator(status: character.status, species: character.species)
104 | }
105 | .padding(16)
106 | }
107 | .clipShape(RoundedRectangle(cornerRadius: 16))
108 | .padding(.horizontal)
109 | }
110 | }
111 |
112 | struct HeaderBackground: View {
113 | var body: some View {
114 | RadialGradient(
115 | gradient: Gradient(colors: [Color.blue.opacity(0.7), Color.white]),
116 | center: .center,
117 | startRadius: 5,
118 | endRadius: 300
119 | )
120 | }
121 | }
122 |
123 | struct CharacterPortraitView: View {
124 | let imageUrl: String
125 |
126 | var body: some View {
127 | ZStack {
128 | // Portal-like background
129 | PortalBackground()
130 |
131 | // Character image
132 | AsyncImage(url: URL(string: imageUrl)) { image in
133 | image
134 | .resizable()
135 | .aspectRatio(contentMode: .fill)
136 | } placeholder: {
137 | ProgressView()
138 | }
139 | .frame(width: 210, height: 210)
140 | .clipShape(Circle())
141 | .overlay(
142 | Circle()
143 | .stroke(Color.white, lineWidth: 5)
144 | )
145 | }
146 | }
147 | }
148 |
149 | struct PortalBackground: View {
150 | var body: some View {
151 | RadialGradient(
152 | gradient: Gradient(colors: [Color.green, Color.green.opacity(0.5)]),
153 | center: .center,
154 | startRadius: 5,
155 | endRadius: 120
156 | )
157 | .clipShape(Circle())
158 | .frame(width: 240, height: 240)
159 | }
160 | }
161 |
162 | struct StatusIndicator: View {
163 | let status: String
164 | let species: String
165 |
166 | var body: some View {
167 | HStack {
168 | Circle()
169 | .fill(statusColor)
170 | .frame(width: 10, height: 10)
171 |
172 | Text("\(status) - \(species)")
173 | .font(.system(size: 16, weight: .medium))
174 | }
175 | .padding(.horizontal, 16)
176 | .padding(.vertical, 10)
177 | .background(statusColor.opacity(0.15))
178 | .overlay(
179 | RoundedRectangle(cornerRadius: 8)
180 | .stroke(statusColor, lineWidth: 1.5)
181 | )
182 | .clipShape(RoundedRectangle(cornerRadius: 8))
183 | }
184 |
185 | // Computed property for status color
186 | private var statusColor: Color {
187 | switch status {
188 | case "Alive":
189 | return .green
190 | case "Dead":
191 | return .red
192 | default:
193 | return .gray
194 | }
195 | }
196 | }
197 |
198 | struct SectionTitle: View {
199 | let title: String
200 | let systemName: String
201 | let tint: Color
202 |
203 | var body: some View {
204 | HStack {
205 | Image(systemName: systemName)
206 | .foregroundColor(tint)
207 |
208 | Text(title)
209 | .font(.system(size: 20, weight: .bold))
210 | .foregroundColor(tint)
211 | }
212 | }
213 | }
214 |
215 | struct InfoRow: View {
216 | let label: String
217 | let value: String
218 | var highlight: Bool = false
219 |
220 | var body: some View {
221 | VStack(spacing: 4) {
222 | HStack {
223 | Text(label)
224 | .font(.system(size: 16))
225 | .foregroundColor(.secondary)
226 |
227 | Spacer()
228 |
229 | Text(value)
230 | .font(.system(size: 16, weight: highlight ? .bold : .medium))
231 | .foregroundColor(highlight ? .blue : .primary)
232 | }
233 |
234 | Divider()
235 | }
236 | }
237 | }
238 |
239 | struct CardSection: View {
240 | let content: Content
241 |
242 | init(@ViewBuilder content: () -> Content) {
243 | self.content = content()
244 | }
245 |
246 | var body: some View {
247 | VStack(alignment: .leading, spacing: 0) {
248 | content
249 | }
250 | .padding(16)
251 | .background(Color.white)
252 | .clipShape(RoundedRectangle(cornerRadius: 12))
253 | .shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2)
254 | }
255 | }
256 |
257 | struct EpisodesSection: View {
258 | let episodes: [CharacterDetail.Episode?]
259 |
260 | var body: some View {
261 | CardSection {
262 | // Header with title and count
263 | EpisodesSectionHeader(count: episodes.count)
264 |
265 | // Episodes list
266 | EpisodesList(episodes: episodes)
267 | }
268 | .background(Color.purple.opacity(0.05))
269 | }
270 | }
271 |
272 | struct EpisodesSectionHeader: View {
273 | let count: Int
274 |
275 | var body: some View {
276 | HStack {
277 | SectionTitle(title: "Episodes", systemName: "tv.fill", tint: .purple)
278 |
279 | Spacer()
280 |
281 | // Episode count badge
282 | Text("\(count)")
283 | .font(.system(size: 14, weight: .bold))
284 | .padding(.horizontal, 8)
285 | .padding(.vertical, 4)
286 | .background(Color.purple.opacity(0.2))
287 | .foregroundColor(.purple)
288 | .clipShape(RoundedRectangle(cornerRadius: 6))
289 | }
290 | }
291 | }
292 |
293 | struct EpisodesList: View {
294 | let episodes: [CharacterDetail.Episode?]
295 |
296 | var body: some View {
297 | VStack(alignment: .leading, spacing: 0) {
298 | ForEach(Array(episodes.enumerated()), id: \.element) { index, episode in
299 | if let episode = episode {
300 | EpisodeRow(episode: episode, index: index)
301 |
302 | // Don't add divider after the last item
303 | if index < episodes.count - 1 {
304 | Divider()
305 | .padding(.leading, 44)
306 | }
307 | }
308 | }
309 | }
310 | .padding(.top, 12)
311 | }
312 | }
313 |
314 | struct EpisodeRow: View {
315 | let episode: CharacterDetail.Episode
316 | let index: Int
317 |
318 | var body: some View {
319 | HStack(spacing: 12) {
320 | // Episode number indicator
321 | ZStack {
322 | Circle()
323 | .fill(Color.purple.opacity(0.15))
324 | .frame(width: 32, height: 32)
325 |
326 | Text("\(index + 1)")
327 | .font(.system(size: 14, weight: .bold))
328 | .foregroundColor(.purple)
329 | }
330 |
331 | // Episode details
332 | VStack(alignment: .leading, spacing: 2) {
333 | Text(episode.name)
334 | .font(.system(size: 16, weight: .medium))
335 |
336 | Text(episode.air_date)
337 | .font(.system(size: 12))
338 | .foregroundColor(.secondary)
339 | }
340 | }
341 | .padding(.vertical, 8)
342 | }
343 | }
344 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright [yyyy] [name of copyright owner]
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
203 |
204 |
--------------------------------------------------------------------------------
/androidApp/src/main/java/dev/johnoreilly/mortycomposekmm/ui/characters/CharacterDetailView.kt:
--------------------------------------------------------------------------------
1 | package dev.johnoreilly.mortycomposekmm.ui.characters
2 |
3 | import android.annotation.SuppressLint
4 | import androidx.compose.animation.core.*
5 | import androidx.compose.foundation.background
6 | import androidx.compose.foundation.border
7 | import androidx.compose.foundation.layout.*
8 | import androidx.compose.foundation.lazy.LazyColumn
9 | import androidx.compose.foundation.shape.CircleShape
10 | import androidx.compose.foundation.shape.RoundedCornerShape
11 | import androidx.compose.material.icons.Icons
12 | import androidx.compose.material.icons.filled.ArrowBack
13 | import androidx.compose.material.icons.filled.LocationOn
14 | import androidx.compose.material.icons.filled.Person
15 | import androidx.compose.material.icons.filled.Tv
16 | import androidx.compose.material3.*
17 | import androidx.compose.runtime.*
18 | import androidx.compose.ui.Alignment
19 | import androidx.compose.ui.Modifier
20 | import androidx.compose.ui.draw.clip
21 | import androidx.compose.ui.draw.scale
22 | import androidx.compose.ui.graphics.Brush
23 | import androidx.compose.ui.graphics.Color
24 | import androidx.compose.ui.graphics.vector.ImageVector
25 | import androidx.compose.ui.layout.ContentScale
26 | import androidx.compose.ui.text.font.FontWeight
27 | import androidx.compose.ui.unit.dp
28 | import coil.compose.AsyncImage
29 | import dev.johnoreilly.mortycomposekmm.fragment.CharacterDetail
30 | import dev.johnoreilly.mortycomposekmm.shared.viewmodel.CharactersViewModel
31 | import dev.johnoreilly.mortycomposekmm.ui.components.MortyDetailTopAppBar
32 | import dev.johnoreilly.mortycomposekmm.ui.components.PortalLoadingAnimation
33 | import dev.johnoreilly.mortycomposekmm.ui.theme.getStatusColor
34 | import org.koin.compose.koinInject
35 |
36 | @Composable
37 | fun CharacterDetailView(characterId: String, popBack: () -> Unit, updateTitle: (String) -> Unit) {
38 | val viewModel: CharactersViewModel = koinInject()
39 | val (character, setCharacter) = remember { mutableStateOf(null) }
40 |
41 | LaunchedEffect(characterId) {
42 | setCharacter(viewModel.getCharacter(characterId))
43 | }
44 |
45 | // Update the shared title when character is loaded
46 | LaunchedEffect(character) {
47 | character?.let {
48 | updateTitle(it.name)
49 | }
50 | }
51 |
52 | Box(
53 | modifier = Modifier.fillMaxSize()
54 | ) {
55 | character?.let { char ->
56 | LazyColumn(
57 | modifier = Modifier
58 | .fillMaxSize()
59 | .padding(16.dp)
60 | ) {
61 | // Character header with image and basic info
62 | item {
63 | CharacterHeader(char)
64 | }
65 |
66 | // Character details section
67 | item {
68 | Card(
69 | modifier = Modifier
70 | .fillMaxWidth()
71 | .padding(vertical = 16.dp),
72 | shape = MaterialTheme.shapes.medium,
73 | elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
74 | colors = CardDefaults.cardColors(
75 | containerColor = MaterialTheme.colorScheme.surface
76 | )
77 | ) {
78 | Column(
79 | modifier = Modifier.padding(16.dp)
80 | ) {
81 | // Information section with themed background
82 | Card(
83 | modifier = Modifier
84 | .fillMaxWidth(),
85 | shape = MaterialTheme.shapes.small,
86 | colors = CardDefaults.cardColors(
87 | containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
88 | ),
89 | elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
90 | ) {
91 | Column(
92 | modifier = Modifier.padding(16.dp)
93 | ) {
94 | SectionTitle(
95 | title = "Information",
96 | icon = Icons.Default.Person
97 | )
98 |
99 | Spacer(modifier = Modifier.height(12.dp))
100 |
101 | InfoRow(label = "Status", value = char.status, highlight = true)
102 | InfoRow(label = "Species", value = char.species)
103 | if (char.type.isNotEmpty()) {
104 | InfoRow(label = "Type", value = char.type)
105 | }
106 | InfoRow(label = "Gender", value = char.gender)
107 | }
108 | }
109 |
110 | Spacer(modifier = Modifier.height(16.dp))
111 |
112 | // Location section with themed background
113 | Card(
114 | modifier = Modifier
115 | .fillMaxWidth(),
116 | shape = MaterialTheme.shapes.small,
117 | colors = CardDefaults.cardColors(
118 | containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.2f)
119 | ),
120 | elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
121 | ) {
122 | Column(
123 | modifier = Modifier.padding(16.dp)
124 | ) {
125 | SectionTitle(
126 | title = "Origin & Location",
127 | icon = Icons.Default.LocationOn,
128 | tint = MaterialTheme.colorScheme.tertiary
129 | )
130 |
131 | Spacer(modifier = Modifier.height(12.dp))
132 |
133 | InfoRow(label = "Origin", value = char.origin?.name ?: "Unknown")
134 | InfoRow(label = "Last known location", value = char.location?.name ?: "Unknown", highlight = true)
135 | }
136 | }
137 | }
138 | }
139 | }
140 |
141 | // Episodes section
142 | item {
143 | Card(
144 | modifier = Modifier
145 | .fillMaxWidth()
146 | .padding(bottom = 16.dp),
147 | shape = MaterialTheme.shapes.medium,
148 | elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
149 | colors = CardDefaults.cardColors(
150 | containerColor = MaterialTheme.colorScheme.surface
151 | )
152 | ) {
153 | Column(
154 | modifier = Modifier.padding(16.dp)
155 | ) {
156 | // Episodes header with count badge
157 | Row(
158 | modifier = Modifier.fillMaxWidth(),
159 | verticalAlignment = Alignment.CenterVertically
160 | ) {
161 | SectionTitle(
162 | title = "Episodes",
163 | icon = Icons.Default.Tv,
164 | tint = dev.johnoreilly.mortycomposekmm.ui.theme.MeeseeksBlue
165 | )
166 |
167 | Spacer(modifier = Modifier.weight(1f))
168 |
169 | // Episode count badge
170 | Card(
171 | shape = MaterialTheme.shapes.small,
172 | colors = CardDefaults.cardColors(
173 | containerColor = dev.johnoreilly.mortycomposekmm.ui.theme.MeeseeksBlue.copy(alpha = 0.2f)
174 | )
175 | ) {
176 | Text(
177 | text = "${char.episode.size}",
178 | style = MaterialTheme.typography.bodyMedium,
179 | fontWeight = FontWeight.Bold,
180 | color = dev.johnoreilly.mortycomposekmm.ui.theme.MeeseeksBlue,
181 | modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
182 | )
183 | }
184 | }
185 |
186 | Spacer(modifier = Modifier.height(12.dp))
187 |
188 | // Episodes list with styled background
189 | Card(
190 | modifier = Modifier.fillMaxWidth(),
191 | shape = MaterialTheme.shapes.small,
192 | colors = CardDefaults.cardColors(
193 | containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.1f)
194 | ),
195 | elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
196 | ) {
197 | CharacterEpisodeList(char)
198 | }
199 | }
200 | }
201 | }
202 | }
203 | } ?: run {
204 | // Loading state with portal-themed animation
205 | Box(
206 | modifier = Modifier
207 | .fillMaxSize(),
208 | contentAlignment = Alignment.Center
209 | ) {
210 | PortalLoadingAnimation()
211 | }
212 | }
213 | }
214 | }
215 |
216 | @Composable
217 | private fun CharacterHeader(character: CharacterDetail) {
218 | Card(
219 | modifier = Modifier.fillMaxWidth(),
220 | shape = MaterialTheme.shapes.large,
221 | elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
222 | colors = CardDefaults.cardColors(
223 | containerColor = MaterialTheme.colorScheme.surface
224 | )
225 | ) {
226 | Box(
227 | modifier = Modifier
228 | .fillMaxWidth()
229 | .background(
230 | brush = androidx.compose.ui.graphics.Brush.radialGradient(
231 | colors = listOf(
232 | MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
233 | MaterialTheme.colorScheme.surface
234 | ),
235 | radius = 800f,
236 | center = androidx.compose.ui.geometry.Offset(0.5f, 0.5f)
237 | )
238 | ),
239 | contentAlignment = Alignment.Center
240 | ) {
241 | Column(
242 | modifier = Modifier.padding(16.dp),
243 | horizontalAlignment = Alignment.CenterHorizontally
244 | ) {
245 | // Character image with portal-like border effect
246 | Box(
247 | modifier = Modifier
248 | .padding(8.dp)
249 | .size(240.dp)
250 | .background(
251 | brush = androidx.compose.ui.graphics.Brush.radialGradient(
252 | colors = listOf(
253 | dev.johnoreilly.mortycomposekmm.ui.theme.PortalSwirl,
254 | dev.johnoreilly.mortycomposekmm.ui.theme.PortalGreen
255 | ),
256 | center = androidx.compose.ui.geometry.Offset(0.5f, 0.5f)
257 | ),
258 | shape = CircleShape
259 | ),
260 | contentAlignment = Alignment.Center
261 | ) {
262 | AsyncImage(
263 | model = character.image,
264 | contentDescription = character.name,
265 | contentScale = ContentScale.Crop,
266 | modifier = Modifier
267 | .size(210.dp)
268 | .clip(CircleShape)
269 | .border(5.dp, MaterialTheme.colorScheme.surface, CircleShape)
270 | )
271 | }
272 |
273 | Spacer(modifier = Modifier.height(16.dp))
274 |
275 | // Character name with enhanced styling
276 | Text(
277 | text = character.name,
278 | style = MaterialTheme.typography.headlineMedium,
279 | fontWeight = FontWeight.Bold,
280 | color = MaterialTheme.colorScheme.onSurface,
281 | modifier = Modifier.padding(horizontal = 16.dp),
282 | maxLines = 2,
283 | overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
284 | )
285 |
286 | Spacer(modifier = Modifier.height(4.dp))
287 |
288 | // Enhanced status indicator
289 | val statusColor = MaterialTheme.colorScheme.getStatusColor(character.status)
290 | Card(
291 | modifier = Modifier
292 | .padding(top = 12.dp)
293 | .align(Alignment.CenterHorizontally)
294 | .border(
295 | width = 1.5f.dp,
296 | color = statusColor,
297 | shape = MaterialTheme.shapes.medium
298 | ),
299 | shape = MaterialTheme.shapes.medium,
300 | colors = CardDefaults.cardColors(
301 | containerColor = statusColor.copy(alpha = 0.15f)
302 | ),
303 | elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
304 | ) {
305 | Row(
306 | verticalAlignment = Alignment.CenterVertically,
307 | horizontalArrangement = Arrangement.Center,
308 | modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
309 | ) {
310 | Box(
311 | modifier = Modifier
312 | .size(16.dp)
313 | .clip(CircleShape)
314 | .background(statusColor)
315 | )
316 | Text(
317 | text = " ${character.status} - ${character.species}",
318 | style = MaterialTheme.typography.bodyLarge,
319 | fontWeight = FontWeight.Medium,
320 | modifier = Modifier.padding(start = 8.dp)
321 | )
322 | }
323 | }
324 | }
325 | }
326 | }
327 | }
328 |
329 | @Composable
330 | private fun SectionTitle(
331 | title: String,
332 | icon: ImageVector,
333 | tint: Color = MaterialTheme.colorScheme.primary
334 | ) {
335 | Row(
336 | verticalAlignment = Alignment.CenterVertically
337 | ) {
338 | Icon(
339 | imageVector = icon,
340 | contentDescription = null,
341 | tint = tint
342 | )
343 | Text(
344 | text = title,
345 | style = MaterialTheme.typography.titleLarge,
346 | fontWeight = FontWeight.Bold,
347 | color = tint,
348 | modifier = Modifier.padding(start = 8.dp)
349 | )
350 | }
351 | }
352 |
353 | @Composable
354 | private fun InfoRow(
355 | label: String,
356 | value: String,
357 | highlight: Boolean = false
358 | ) {
359 | Row(
360 | modifier = Modifier
361 | .fillMaxWidth()
362 | .padding(vertical = 4.dp),
363 | horizontalArrangement = Arrangement.SpaceBetween
364 | ) {
365 | Text(
366 | text = label,
367 | style = MaterialTheme.typography.bodyMedium,
368 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
369 | )
370 | Text(
371 | text = value,
372 | style = MaterialTheme.typography.bodyMedium,
373 | fontWeight = if (highlight) FontWeight.Bold else FontWeight.Medium,
374 | color = if (highlight) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
375 | )
376 | }
377 | Divider(
378 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
379 | modifier = Modifier.padding(vertical = 4.dp)
380 | )
381 | }
382 |
383 | @Composable
384 | private fun CharacterEpisodeList(character: CharacterDetail) {
385 | Column(modifier = Modifier.padding(8.dp)) {
386 | character.episode.let { episodeList ->
387 | episodeList.filterNotNull().forEachIndexed { index, episode ->
388 | // Episode item with alternating background
389 | Row(
390 | modifier = Modifier
391 | .fillMaxWidth()
392 | .padding(vertical = 8.dp),
393 | verticalAlignment = Alignment.CenterVertically
394 | ) {
395 | // Episode number indicator
396 | Box(
397 | modifier = Modifier
398 | .size(32.dp)
399 | .background(
400 | color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f),
401 | shape = CircleShape
402 | ),
403 | contentAlignment = Alignment.Center
404 | ) {
405 | Text(
406 | text = "${index + 1}",
407 | style = MaterialTheme.typography.bodyMedium,
408 | fontWeight = FontWeight.Bold,
409 | color = MaterialTheme.colorScheme.secondary
410 | )
411 | }
412 |
413 | // Episode details
414 | Column(
415 | modifier = Modifier
416 | .padding(start = 12.dp)
417 | .weight(1f)
418 | ) {
419 | Text(
420 | text = episode.name,
421 | style = MaterialTheme.typography.bodyLarge,
422 | fontWeight = FontWeight.Medium,
423 | color = MaterialTheme.colorScheme.onSurface
424 | )
425 |
426 | Spacer(modifier = Modifier.height(2.dp))
427 |
428 | Text(
429 | text = episode.air_date,
430 | style = MaterialTheme.typography.bodySmall,
431 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
432 | )
433 | }
434 | }
435 |
436 | // Don't add divider after the last item
437 | if (index < episodeList.size - 1) {
438 | Divider(
439 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
440 | modifier = Modifier.padding(start = 44.dp) // Align with text, not with the circle
441 | )
442 | }
443 | }
444 | }
445 | }
446 | }
447 |
--------------------------------------------------------------------------------
/iosApp/iosApp.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 54;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 1A320EB52766339400DFE888 /* EpisodesListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A320EB42766339400DFE888 /* EpisodesListRowView.swift */; };
11 | 1A78A3132BF75E4C00D39199 /* KMPViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A78A3122BF75E4C00D39199 /* KMPViewModel.swift */; };
12 | 1A78A3152BF75FEF00D39199 /* KMPObservableViewModelCore in Frameworks */ = {isa = PBXBuildFile; productRef = 1A78A3142BF75FEF00D39199 /* KMPObservableViewModelCore */; };
13 | 1A78A3172BF75FF400D39199 /* KMPObservableViewModelSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 1A78A3162BF75FF400D39199 /* KMPObservableViewModelSwiftUI */; };
14 | 1AC98ADA2E4914A000FCC8C2 /* LocationsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AC98AD82E4914A000FCC8C2 /* LocationsListView.swift */; };
15 | 1AC98ADB2E4914A000FCC8C2 /* LocationsListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AC98AD72E4914A000FCC8C2 /* LocationsListRowView.swift */; };
16 | 1AC98ADD2E49163F00FCC8C2 /* CharacterDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AC98ADC2E49163F00FCC8C2 /* CharacterDetailView.swift */; };
17 | 1AF7E3CE259389C800905739 /* CharactersListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AF7E3CD259389C800905739 /* CharactersListView.swift */; };
18 | 1AF7E3D4259389FB00905739 /* EpisodesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AF7E3D3259389FB00905739 /* EpisodesListView.swift */; };
19 | 1AF7E3E025939BEF00905739 /* CharactersListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AF7E3DF25939BEF00905739 /* CharactersListRowView.swift */; };
20 | 7555FF7F242A565900829871 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF7E242A565900829871 /* AppDelegate.swift */; };
21 | 7555FF81242A565900829871 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF80242A565900829871 /* SceneDelegate.swift */; };
22 | 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; };
23 | 7555FF85242A565B00829871 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7555FF84242A565B00829871 /* Assets.xcassets */; };
24 | 7555FF88242A565B00829871 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7555FF87242A565B00829871 /* Preview Assets.xcassets */; };
25 | 7555FF8B242A565B00829871 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7555FF89242A565B00829871 /* LaunchScreen.storyboard */; };
26 | 7555FF96242A565B00829871 /* iosAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF95242A565B00829871 /* iosAppTests.swift */; };
27 | 7555FFA1242A565B00829871 /* iosAppUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FFA0242A565B00829871 /* iosAppUITests.swift */; };
28 | /* End PBXBuildFile section */
29 |
30 | /* Begin PBXContainerItemProxy section */
31 | 7555FF92242A565B00829871 /* PBXContainerItemProxy */ = {
32 | isa = PBXContainerItemProxy;
33 | containerPortal = 7555FF73242A565900829871 /* Project object */;
34 | proxyType = 1;
35 | remoteGlobalIDString = 7555FF7A242A565900829871;
36 | remoteInfo = iosApp;
37 | };
38 | 7555FF9D242A565B00829871 /* PBXContainerItemProxy */ = {
39 | isa = PBXContainerItemProxy;
40 | containerPortal = 7555FF73242A565900829871 /* Project object */;
41 | proxyType = 1;
42 | remoteGlobalIDString = 7555FF7A242A565900829871;
43 | remoteInfo = iosApp;
44 | };
45 | /* End PBXContainerItemProxy section */
46 |
47 | /* Begin PBXCopyFilesBuildPhase section */
48 | 7555FFB4242A642300829871 /* Embed Frameworks */ = {
49 | isa = PBXCopyFilesBuildPhase;
50 | buildActionMask = 2147483647;
51 | dstPath = "";
52 | dstSubfolderSpec = 10;
53 | files = (
54 | );
55 | name = "Embed Frameworks";
56 | runOnlyForDeploymentPostprocessing = 0;
57 | };
58 | /* End PBXCopyFilesBuildPhase section */
59 |
60 | /* Begin PBXFileReference section */
61 | 1A320EB42766339400DFE888 /* EpisodesListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesListRowView.swift; sourceTree = ""; };
62 | 1A78A3122BF75E4C00D39199 /* KMPViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPViewModel.swift; sourceTree = ""; };
63 | 1AC98AD72E4914A000FCC8C2 /* LocationsListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsListRowView.swift; sourceTree = ""; };
64 | 1AC98AD82E4914A000FCC8C2 /* LocationsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsListView.swift; sourceTree = ""; };
65 | 1AC98ADC2E49163F00FCC8C2 /* CharacterDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterDetailView.swift; sourceTree = ""; };
66 | 1AF7E3CD259389C800905739 /* CharactersListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharactersListView.swift; sourceTree = ""; };
67 | 1AF7E3D3259389FB00905739 /* EpisodesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesListView.swift; sourceTree = ""; };
68 | 1AF7E3DF25939BEF00905739 /* CharactersListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharactersListRowView.swift; sourceTree = ""; };
69 | 7555FF7B242A565900829871 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
70 | 7555FF7E242A565900829871 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
71 | 7555FF80242A565900829871 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
72 | 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
73 | 7555FF84242A565B00829871 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
74 | 7555FF87242A565B00829871 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
75 | 7555FF8A242A565B00829871 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
76 | 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
77 | 7555FF91242A565B00829871 /* iosAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = iosAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
78 | 7555FF95242A565B00829871 /* iosAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iosAppTests.swift; sourceTree = ""; };
79 | 7555FF97242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
80 | 7555FF9C242A565B00829871 /* iosAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = iosAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
81 | 7555FFA0242A565B00829871 /* iosAppUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iosAppUITests.swift; sourceTree = ""; };
82 | 7555FFA2242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
83 | 7555FFB1242A642300829871 /* shared.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = shared.framework; path = "../shared/build/xcode-frameworks/shared.framework"; sourceTree = ""; };
84 | /* End PBXFileReference section */
85 |
86 | /* Begin PBXFrameworksBuildPhase section */
87 | 7555FF78242A565900829871 /* Frameworks */ = {
88 | isa = PBXFrameworksBuildPhase;
89 | buildActionMask = 2147483647;
90 | files = (
91 | 1A78A3172BF75FF400D39199 /* KMPObservableViewModelSwiftUI in Frameworks */,
92 | 1A78A3152BF75FEF00D39199 /* KMPObservableViewModelCore in Frameworks */,
93 | );
94 | runOnlyForDeploymentPostprocessing = 0;
95 | };
96 | 7555FF8E242A565B00829871 /* Frameworks */ = {
97 | isa = PBXFrameworksBuildPhase;
98 | buildActionMask = 2147483647;
99 | files = (
100 | );
101 | runOnlyForDeploymentPostprocessing = 0;
102 | };
103 | 7555FF99242A565B00829871 /* Frameworks */ = {
104 | isa = PBXFrameworksBuildPhase;
105 | buildActionMask = 2147483647;
106 | files = (
107 | );
108 | runOnlyForDeploymentPostprocessing = 0;
109 | };
110 | /* End PBXFrameworksBuildPhase section */
111 |
112 | /* Begin PBXGroup section */
113 | 1AC98AD92E4914A000FCC8C2 /* Locations */ = {
114 | isa = PBXGroup;
115 | children = (
116 | 1AC98AD72E4914A000FCC8C2 /* LocationsListRowView.swift */,
117 | 1AC98AD82E4914A000FCC8C2 /* LocationsListView.swift */,
118 | );
119 | path = Locations;
120 | sourceTree = "";
121 | };
122 | 1AF7E3CA2593899700905739 /* Features */ = {
123 | isa = PBXGroup;
124 | children = (
125 | 1AC98AD92E4914A000FCC8C2 /* Locations */,
126 | 1AF7E3CC259389B000905739 /* Episodes */,
127 | 1AF7E3CB259389A300905739 /* Characters */,
128 | );
129 | path = Features;
130 | sourceTree = "";
131 | };
132 | 1AF7E3CB259389A300905739 /* Characters */ = {
133 | isa = PBXGroup;
134 | children = (
135 | 1AC98ADC2E49163F00FCC8C2 /* CharacterDetailView.swift */,
136 | 1AF7E3CD259389C800905739 /* CharactersListView.swift */,
137 | 1AF7E3DF25939BEF00905739 /* CharactersListRowView.swift */,
138 | );
139 | path = Characters;
140 | sourceTree = "";
141 | };
142 | 1AF7E3CC259389B000905739 /* Episodes */ = {
143 | isa = PBXGroup;
144 | children = (
145 | 1AF7E3D3259389FB00905739 /* EpisodesListView.swift */,
146 | 1A320EB42766339400DFE888 /* EpisodesListRowView.swift */,
147 | );
148 | path = Episodes;
149 | sourceTree = "";
150 | };
151 | 7555FF72242A565900829871 = {
152 | isa = PBXGroup;
153 | children = (
154 | 7555FF7D242A565900829871 /* iosApp */,
155 | 7555FF94242A565B00829871 /* iosAppTests */,
156 | 7555FF9F242A565B00829871 /* iosAppUITests */,
157 | 7555FF7C242A565900829871 /* Products */,
158 | 7555FFB0242A642200829871 /* Frameworks */,
159 | );
160 | sourceTree = "";
161 | };
162 | 7555FF7C242A565900829871 /* Products */ = {
163 | isa = PBXGroup;
164 | children = (
165 | 7555FF7B242A565900829871 /* iosApp.app */,
166 | 7555FF91242A565B00829871 /* iosAppTests.xctest */,
167 | 7555FF9C242A565B00829871 /* iosAppUITests.xctest */,
168 | );
169 | name = Products;
170 | sourceTree = "";
171 | };
172 | 7555FF7D242A565900829871 /* iosApp */ = {
173 | isa = PBXGroup;
174 | children = (
175 | 1AF7E3CA2593899700905739 /* Features */,
176 | 7555FF7E242A565900829871 /* AppDelegate.swift */,
177 | 7555FF80242A565900829871 /* SceneDelegate.swift */,
178 | 7555FF82242A565900829871 /* ContentView.swift */,
179 | 7555FF84242A565B00829871 /* Assets.xcassets */,
180 | 7555FF89242A565B00829871 /* LaunchScreen.storyboard */,
181 | 7555FF8C242A565B00829871 /* Info.plist */,
182 | 7555FF86242A565B00829871 /* Preview Content */,
183 | 1A78A3122BF75E4C00D39199 /* KMPViewModel.swift */,
184 | );
185 | path = iosApp;
186 | sourceTree = "";
187 | };
188 | 7555FF86242A565B00829871 /* Preview Content */ = {
189 | isa = PBXGroup;
190 | children = (
191 | 7555FF87242A565B00829871 /* Preview Assets.xcassets */,
192 | );
193 | path = "Preview Content";
194 | sourceTree = "";
195 | };
196 | 7555FF94242A565B00829871 /* iosAppTests */ = {
197 | isa = PBXGroup;
198 | children = (
199 | 7555FF95242A565B00829871 /* iosAppTests.swift */,
200 | 7555FF97242A565B00829871 /* Info.plist */,
201 | );
202 | path = iosAppTests;
203 | sourceTree = "";
204 | };
205 | 7555FF9F242A565B00829871 /* iosAppUITests */ = {
206 | isa = PBXGroup;
207 | children = (
208 | 7555FFA0242A565B00829871 /* iosAppUITests.swift */,
209 | 7555FFA2242A565B00829871 /* Info.plist */,
210 | );
211 | path = iosAppUITests;
212 | sourceTree = "";
213 | };
214 | 7555FFB0242A642200829871 /* Frameworks */ = {
215 | isa = PBXGroup;
216 | children = (
217 | 7555FFB1242A642300829871 /* shared.framework */,
218 | );
219 | name = Frameworks;
220 | sourceTree = "";
221 | };
222 | /* End PBXGroup section */
223 |
224 | /* Begin PBXNativeTarget section */
225 | 7555FF7A242A565900829871 /* iosApp */ = {
226 | isa = PBXNativeTarget;
227 | buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */;
228 | buildPhases = (
229 | 7555FFB5242A651A00829871 /* ShellScript */,
230 | 7555FF77242A565900829871 /* Sources */,
231 | 7555FF78242A565900829871 /* Frameworks */,
232 | 7555FF79242A565900829871 /* Resources */,
233 | 7555FFB4242A642300829871 /* Embed Frameworks */,
234 | );
235 | buildRules = (
236 | );
237 | dependencies = (
238 | );
239 | name = iosApp;
240 | packageProductDependencies = (
241 | 1A78A3142BF75FEF00D39199 /* KMPObservableViewModelCore */,
242 | 1A78A3162BF75FF400D39199 /* KMPObservableViewModelSwiftUI */,
243 | );
244 | productName = iosApp;
245 | productReference = 7555FF7B242A565900829871 /* iosApp.app */;
246 | productType = "com.apple.product-type.application";
247 | };
248 | 7555FF90242A565B00829871 /* iosAppTests */ = {
249 | isa = PBXNativeTarget;
250 | buildConfigurationList = 7555FFA8242A565B00829871 /* Build configuration list for PBXNativeTarget "iosAppTests" */;
251 | buildPhases = (
252 | 7555FF8D242A565B00829871 /* Sources */,
253 | 7555FF8E242A565B00829871 /* Frameworks */,
254 | 7555FF8F242A565B00829871 /* Resources */,
255 | );
256 | buildRules = (
257 | );
258 | dependencies = (
259 | 7555FF93242A565B00829871 /* PBXTargetDependency */,
260 | );
261 | name = iosAppTests;
262 | productName = iosAppTests;
263 | productReference = 7555FF91242A565B00829871 /* iosAppTests.xctest */;
264 | productType = "com.apple.product-type.bundle.unit-test";
265 | };
266 | 7555FF9B242A565B00829871 /* iosAppUITests */ = {
267 | isa = PBXNativeTarget;
268 | buildConfigurationList = 7555FFAB242A565B00829871 /* Build configuration list for PBXNativeTarget "iosAppUITests" */;
269 | buildPhases = (
270 | 7555FF98242A565B00829871 /* Sources */,
271 | 7555FF99242A565B00829871 /* Frameworks */,
272 | 7555FF9A242A565B00829871 /* Resources */,
273 | );
274 | buildRules = (
275 | );
276 | dependencies = (
277 | 7555FF9E242A565B00829871 /* PBXTargetDependency */,
278 | );
279 | name = iosAppUITests;
280 | productName = iosAppUITests;
281 | productReference = 7555FF9C242A565B00829871 /* iosAppUITests.xctest */;
282 | productType = "com.apple.product-type.bundle.ui-testing";
283 | };
284 | /* End PBXNativeTarget section */
285 |
286 | /* Begin PBXProject section */
287 | 7555FF73242A565900829871 /* Project object */ = {
288 | isa = PBXProject;
289 | attributes = {
290 | LastSwiftUpdateCheck = 1130;
291 | LastUpgradeCheck = 1130;
292 | ORGANIZATIONNAME = orgName;
293 | TargetAttributes = {
294 | 7555FF7A242A565900829871 = {
295 | CreatedOnToolsVersion = 11.3.1;
296 | };
297 | 7555FF90242A565B00829871 = {
298 | CreatedOnToolsVersion = 11.3.1;
299 | TestTargetID = 7555FF7A242A565900829871;
300 | };
301 | 7555FF9B242A565B00829871 = {
302 | CreatedOnToolsVersion = 11.3.1;
303 | TestTargetID = 7555FF7A242A565900829871;
304 | };
305 | };
306 | };
307 | buildConfigurationList = 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */;
308 | compatibilityVersion = "Xcode 9.3";
309 | developmentRegion = en;
310 | hasScannedForEncodings = 0;
311 | knownRegions = (
312 | en,
313 | Base,
314 | );
315 | mainGroup = 7555FF72242A565900829871;
316 | packageReferences = (
317 | 1AF7E3E82593A47400905739 /* XCRemoteSwiftPackageReference "Kingfisher" */,
318 | 1A78A3112BF75E1F00D39199 /* XCRemoteSwiftPackageReference "KMP-ObservableViewModel" */,
319 | );
320 | productRefGroup = 7555FF7C242A565900829871 /* Products */;
321 | projectDirPath = "";
322 | projectRoot = "";
323 | targets = (
324 | 7555FF7A242A565900829871 /* iosApp */,
325 | 7555FF90242A565B00829871 /* iosAppTests */,
326 | 7555FF9B242A565B00829871 /* iosAppUITests */,
327 | );
328 | };
329 | /* End PBXProject section */
330 |
331 | /* Begin PBXResourcesBuildPhase section */
332 | 7555FF79242A565900829871 /* Resources */ = {
333 | isa = PBXResourcesBuildPhase;
334 | buildActionMask = 2147483647;
335 | files = (
336 | 7555FF8B242A565B00829871 /* LaunchScreen.storyboard in Resources */,
337 | 7555FF88242A565B00829871 /* Preview Assets.xcassets in Resources */,
338 | 7555FF85242A565B00829871 /* Assets.xcassets in Resources */,
339 | );
340 | runOnlyForDeploymentPostprocessing = 0;
341 | };
342 | 7555FF8F242A565B00829871 /* Resources */ = {
343 | isa = PBXResourcesBuildPhase;
344 | buildActionMask = 2147483647;
345 | files = (
346 | );
347 | runOnlyForDeploymentPostprocessing = 0;
348 | };
349 | 7555FF9A242A565B00829871 /* Resources */ = {
350 | isa = PBXResourcesBuildPhase;
351 | buildActionMask = 2147483647;
352 | files = (
353 | );
354 | runOnlyForDeploymentPostprocessing = 0;
355 | };
356 | /* End PBXResourcesBuildPhase section */
357 |
358 | /* Begin PBXShellScriptBuildPhase section */
359 | 7555FFB5242A651A00829871 /* ShellScript */ = {
360 | isa = PBXShellScriptBuildPhase;
361 | buildActionMask = 2147483647;
362 | files = (
363 | );
364 | inputFileListPaths = (
365 | );
366 | inputPaths = (
367 | );
368 | outputFileListPaths = (
369 | );
370 | outputPaths = (
371 | );
372 | runOnlyForDeploymentPostprocessing = 0;
373 | shellPath = /bin/sh;
374 | shellScript = "cd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode -PXCODE_CONFIGURATION=${CONFIGURATION}\n";
375 | };
376 | /* End PBXShellScriptBuildPhase section */
377 |
378 | /* Begin PBXSourcesBuildPhase section */
379 | 7555FF77242A565900829871 /* Sources */ = {
380 | isa = PBXSourcesBuildPhase;
381 | buildActionMask = 2147483647;
382 | files = (
383 | 7555FF7F242A565900829871 /* AppDelegate.swift in Sources */,
384 | 1AC98ADD2E49163F00FCC8C2 /* CharacterDetailView.swift in Sources */,
385 | 7555FF81242A565900829871 /* SceneDelegate.swift in Sources */,
386 | 1AC98ADA2E4914A000FCC8C2 /* LocationsListView.swift in Sources */,
387 | 1AC98ADB2E4914A000FCC8C2 /* LocationsListRowView.swift in Sources */,
388 | 1A320EB52766339400DFE888 /* EpisodesListRowView.swift in Sources */,
389 | 1AF7E3CE259389C800905739 /* CharactersListView.swift in Sources */,
390 | 1AF7E3E025939BEF00905739 /* CharactersListRowView.swift in Sources */,
391 | 1AF7E3D4259389FB00905739 /* EpisodesListView.swift in Sources */,
392 | 7555FF83242A565900829871 /* ContentView.swift in Sources */,
393 | 1A78A3132BF75E4C00D39199 /* KMPViewModel.swift in Sources */,
394 | );
395 | runOnlyForDeploymentPostprocessing = 0;
396 | };
397 | 7555FF8D242A565B00829871 /* Sources */ = {
398 | isa = PBXSourcesBuildPhase;
399 | buildActionMask = 2147483647;
400 | files = (
401 | 7555FF96242A565B00829871 /* iosAppTests.swift in Sources */,
402 | );
403 | runOnlyForDeploymentPostprocessing = 0;
404 | };
405 | 7555FF98242A565B00829871 /* Sources */ = {
406 | isa = PBXSourcesBuildPhase;
407 | buildActionMask = 2147483647;
408 | files = (
409 | 7555FFA1242A565B00829871 /* iosAppUITests.swift in Sources */,
410 | );
411 | runOnlyForDeploymentPostprocessing = 0;
412 | };
413 | /* End PBXSourcesBuildPhase section */
414 |
415 | /* Begin PBXTargetDependency section */
416 | 7555FF93242A565B00829871 /* PBXTargetDependency */ = {
417 | isa = PBXTargetDependency;
418 | target = 7555FF7A242A565900829871 /* iosApp */;
419 | targetProxy = 7555FF92242A565B00829871 /* PBXContainerItemProxy */;
420 | };
421 | 7555FF9E242A565B00829871 /* PBXTargetDependency */ = {
422 | isa = PBXTargetDependency;
423 | target = 7555FF7A242A565900829871 /* iosApp */;
424 | targetProxy = 7555FF9D242A565B00829871 /* PBXContainerItemProxy */;
425 | };
426 | /* End PBXTargetDependency section */
427 |
428 | /* Begin PBXVariantGroup section */
429 | 7555FF89242A565B00829871 /* LaunchScreen.storyboard */ = {
430 | isa = PBXVariantGroup;
431 | children = (
432 | 7555FF8A242A565B00829871 /* Base */,
433 | );
434 | name = LaunchScreen.storyboard;
435 | sourceTree = "";
436 | };
437 | /* End PBXVariantGroup section */
438 |
439 | /* Begin XCBuildConfiguration section */
440 | 7555FFA3242A565B00829871 /* Debug */ = {
441 | isa = XCBuildConfiguration;
442 | buildSettings = {
443 | ALWAYS_SEARCH_USER_PATHS = NO;
444 | CLANG_ANALYZER_NONNULL = YES;
445 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
446 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
447 | CLANG_CXX_LIBRARY = "libc++";
448 | CLANG_ENABLE_MODULES = YES;
449 | CLANG_ENABLE_OBJC_ARC = YES;
450 | CLANG_ENABLE_OBJC_WEAK = YES;
451 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
452 | CLANG_WARN_BOOL_CONVERSION = YES;
453 | CLANG_WARN_COMMA = YES;
454 | CLANG_WARN_CONSTANT_CONVERSION = YES;
455 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
456 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
457 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
458 | CLANG_WARN_EMPTY_BODY = YES;
459 | CLANG_WARN_ENUM_CONVERSION = YES;
460 | CLANG_WARN_INFINITE_RECURSION = YES;
461 | CLANG_WARN_INT_CONVERSION = YES;
462 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
463 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
464 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
465 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
466 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
467 | CLANG_WARN_STRICT_PROTOTYPES = YES;
468 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
469 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
470 | CLANG_WARN_UNREACHABLE_CODE = YES;
471 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
472 | COPY_PHASE_STRIP = NO;
473 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
474 | ENABLE_STRICT_OBJC_MSGSEND = YES;
475 | ENABLE_TESTABILITY = YES;
476 | GCC_C_LANGUAGE_STANDARD = gnu11;
477 | GCC_DYNAMIC_NO_PIC = NO;
478 | GCC_NO_COMMON_BLOCKS = YES;
479 | GCC_OPTIMIZATION_LEVEL = 0;
480 | GCC_PREPROCESSOR_DEFINITIONS = (
481 | "DEBUG=1",
482 | "$(inherited)",
483 | );
484 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
485 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
486 | GCC_WARN_UNDECLARED_SELECTOR = YES;
487 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
488 | GCC_WARN_UNUSED_FUNCTION = YES;
489 | GCC_WARN_UNUSED_VARIABLE = YES;
490 | IPHONEOS_DEPLOYMENT_TARGET = 16.0;
491 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
492 | MTL_FAST_MATH = YES;
493 | ONLY_ACTIVE_ARCH = YES;
494 | SDKROOT = iphoneos;
495 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
496 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
497 | };
498 | name = Debug;
499 | };
500 | 7555FFA4242A565B00829871 /* Release */ = {
501 | isa = XCBuildConfiguration;
502 | buildSettings = {
503 | ALWAYS_SEARCH_USER_PATHS = NO;
504 | CLANG_ANALYZER_NONNULL = YES;
505 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
506 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
507 | CLANG_CXX_LIBRARY = "libc++";
508 | CLANG_ENABLE_MODULES = YES;
509 | CLANG_ENABLE_OBJC_ARC = YES;
510 | CLANG_ENABLE_OBJC_WEAK = YES;
511 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
512 | CLANG_WARN_BOOL_CONVERSION = YES;
513 | CLANG_WARN_COMMA = YES;
514 | CLANG_WARN_CONSTANT_CONVERSION = YES;
515 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
516 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
517 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
518 | CLANG_WARN_EMPTY_BODY = YES;
519 | CLANG_WARN_ENUM_CONVERSION = YES;
520 | CLANG_WARN_INFINITE_RECURSION = YES;
521 | CLANG_WARN_INT_CONVERSION = YES;
522 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
523 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
524 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
525 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
526 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
527 | CLANG_WARN_STRICT_PROTOTYPES = YES;
528 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
529 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
530 | CLANG_WARN_UNREACHABLE_CODE = YES;
531 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
532 | COPY_PHASE_STRIP = NO;
533 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
534 | ENABLE_NS_ASSERTIONS = NO;
535 | ENABLE_STRICT_OBJC_MSGSEND = YES;
536 | GCC_C_LANGUAGE_STANDARD = gnu11;
537 | GCC_NO_COMMON_BLOCKS = YES;
538 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
539 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
540 | GCC_WARN_UNDECLARED_SELECTOR = YES;
541 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
542 | GCC_WARN_UNUSED_FUNCTION = YES;
543 | GCC_WARN_UNUSED_VARIABLE = YES;
544 | IPHONEOS_DEPLOYMENT_TARGET = 16.0;
545 | MTL_ENABLE_DEBUG_INFO = NO;
546 | MTL_FAST_MATH = YES;
547 | SDKROOT = iphoneos;
548 | SWIFT_COMPILATION_MODE = wholemodule;
549 | SWIFT_OPTIMIZATION_LEVEL = "-O";
550 | VALIDATE_PRODUCT = YES;
551 | };
552 | name = Release;
553 | };
554 | 7555FFA6242A565B00829871 /* Debug */ = {
555 | isa = XCBuildConfiguration;
556 | buildSettings = {
557 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
558 | CODE_SIGN_STYLE = Automatic;
559 | DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
560 | DEVELOPMENT_TEAM = NT77748GS8;
561 | ENABLE_PREVIEWS = YES;
562 | FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)";
563 | INFOPLIST_FILE = iosApp/Info.plist;
564 | IPHONEOS_DEPLOYMENT_TARGET = 16.0;
565 | LD_RUNPATH_SEARCH_PATHS = (
566 | "$(inherited)",
567 | "@executable_path/Frameworks",
568 | );
569 | PRODUCT_BUNDLE_IDENTIFIER = com.surrus.mortycomposekmm;
570 | PRODUCT_NAME = "$(TARGET_NAME)";
571 | SWIFT_VERSION = 5.0;
572 | TARGETED_DEVICE_FAMILY = "1,2";
573 | };
574 | name = Debug;
575 | };
576 | 7555FFA7242A565B00829871 /* Release */ = {
577 | isa = XCBuildConfiguration;
578 | buildSettings = {
579 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
580 | CODE_SIGN_STYLE = Automatic;
581 | DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
582 | DEVELOPMENT_TEAM = NT77748GS8;
583 | ENABLE_PREVIEWS = YES;
584 | FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)";
585 | INFOPLIST_FILE = iosApp/Info.plist;
586 | IPHONEOS_DEPLOYMENT_TARGET = 16.0;
587 | LD_RUNPATH_SEARCH_PATHS = (
588 | "$(inherited)",
589 | "@executable_path/Frameworks",
590 | );
591 | PRODUCT_BUNDLE_IDENTIFIER = com.surrus.mortycomposekmm;
592 | PRODUCT_NAME = "$(TARGET_NAME)";
593 | SWIFT_VERSION = 5.0;
594 | TARGETED_DEVICE_FAMILY = "1,2";
595 | };
596 | name = Release;
597 | };
598 | 7555FFA9242A565B00829871 /* Debug */ = {
599 | isa = XCBuildConfiguration;
600 | buildSettings = {
601 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
602 | BUNDLE_LOADER = "$(TEST_HOST)";
603 | CODE_SIGN_STYLE = Automatic;
604 | INFOPLIST_FILE = iosAppTests/Info.plist;
605 | IPHONEOS_DEPLOYMENT_TARGET = 13.2;
606 | LD_RUNPATH_SEARCH_PATHS = (
607 | "$(inherited)",
608 | "@executable_path/Frameworks",
609 | "@loader_path/Frameworks",
610 | );
611 | PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosAppTests;
612 | PRODUCT_NAME = "$(TARGET_NAME)";
613 | SWIFT_VERSION = 5.0;
614 | TARGETED_DEVICE_FAMILY = "1,2";
615 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/iosApp.app/iosApp";
616 | };
617 | name = Debug;
618 | };
619 | 7555FFAA242A565B00829871 /* Release */ = {
620 | isa = XCBuildConfiguration;
621 | buildSettings = {
622 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
623 | BUNDLE_LOADER = "$(TEST_HOST)";
624 | CODE_SIGN_STYLE = Automatic;
625 | INFOPLIST_FILE = iosAppTests/Info.plist;
626 | IPHONEOS_DEPLOYMENT_TARGET = 13.2;
627 | LD_RUNPATH_SEARCH_PATHS = (
628 | "$(inherited)",
629 | "@executable_path/Frameworks",
630 | "@loader_path/Frameworks",
631 | );
632 | PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosAppTests;
633 | PRODUCT_NAME = "$(TARGET_NAME)";
634 | SWIFT_VERSION = 5.0;
635 | TARGETED_DEVICE_FAMILY = "1,2";
636 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/iosApp.app/iosApp";
637 | };
638 | name = Release;
639 | };
640 | 7555FFAC242A565B00829871 /* Debug */ = {
641 | isa = XCBuildConfiguration;
642 | buildSettings = {
643 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
644 | CODE_SIGN_STYLE = Automatic;
645 | INFOPLIST_FILE = iosAppUITests/Info.plist;
646 | LD_RUNPATH_SEARCH_PATHS = (
647 | "$(inherited)",
648 | "@executable_path/Frameworks",
649 | "@loader_path/Frameworks",
650 | );
651 | PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosAppUITests;
652 | PRODUCT_NAME = "$(TARGET_NAME)";
653 | SWIFT_VERSION = 5.0;
654 | TARGETED_DEVICE_FAMILY = "1,2";
655 | TEST_TARGET_NAME = iosApp;
656 | };
657 | name = Debug;
658 | };
659 | 7555FFAD242A565B00829871 /* Release */ = {
660 | isa = XCBuildConfiguration;
661 | buildSettings = {
662 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
663 | CODE_SIGN_STYLE = Automatic;
664 | INFOPLIST_FILE = iosAppUITests/Info.plist;
665 | LD_RUNPATH_SEARCH_PATHS = (
666 | "$(inherited)",
667 | "@executable_path/Frameworks",
668 | "@loader_path/Frameworks",
669 | );
670 | PRODUCT_BUNDLE_IDENTIFIER = orgIdentifier.iosAppUITests;
671 | PRODUCT_NAME = "$(TARGET_NAME)";
672 | SWIFT_VERSION = 5.0;
673 | TARGETED_DEVICE_FAMILY = "1,2";
674 | TEST_TARGET_NAME = iosApp;
675 | };
676 | name = Release;
677 | };
678 | /* End XCBuildConfiguration section */
679 |
680 | /* Begin XCConfigurationList section */
681 | 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */ = {
682 | isa = XCConfigurationList;
683 | buildConfigurations = (
684 | 7555FFA3242A565B00829871 /* Debug */,
685 | 7555FFA4242A565B00829871 /* Release */,
686 | );
687 | defaultConfigurationIsVisible = 0;
688 | defaultConfigurationName = Release;
689 | };
690 | 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
691 | isa = XCConfigurationList;
692 | buildConfigurations = (
693 | 7555FFA6242A565B00829871 /* Debug */,
694 | 7555FFA7242A565B00829871 /* Release */,
695 | );
696 | defaultConfigurationIsVisible = 0;
697 | defaultConfigurationName = Release;
698 | };
699 | 7555FFA8242A565B00829871 /* Build configuration list for PBXNativeTarget "iosAppTests" */ = {
700 | isa = XCConfigurationList;
701 | buildConfigurations = (
702 | 7555FFA9242A565B00829871 /* Debug */,
703 | 7555FFAA242A565B00829871 /* Release */,
704 | );
705 | defaultConfigurationIsVisible = 0;
706 | defaultConfigurationName = Release;
707 | };
708 | 7555FFAB242A565B00829871 /* Build configuration list for PBXNativeTarget "iosAppUITests" */ = {
709 | isa = XCConfigurationList;
710 | buildConfigurations = (
711 | 7555FFAC242A565B00829871 /* Debug */,
712 | 7555FFAD242A565B00829871 /* Release */,
713 | );
714 | defaultConfigurationIsVisible = 0;
715 | defaultConfigurationName = Release;
716 | };
717 | /* End XCConfigurationList section */
718 |
719 | /* Begin XCRemoteSwiftPackageReference section */
720 | 1A78A3112BF75E1F00D39199 /* XCRemoteSwiftPackageReference "KMP-ObservableViewModel" */ = {
721 | isa = XCRemoteSwiftPackageReference;
722 | repositoryURL = "https://github.com/rickclephas/KMP-ObservableViewModel.git";
723 | requirement = {
724 | kind = exactVersion;
725 | version = "1.0.0-BETA-2";
726 | };
727 | };
728 | 1AF7E3E82593A47400905739 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
729 | isa = XCRemoteSwiftPackageReference;
730 | repositoryURL = "https://github.com/onevcat/Kingfisher.git";
731 | requirement = {
732 | kind = upToNextMajorVersion;
733 | minimumVersion = 5.15.8;
734 | };
735 | };
736 | /* End XCRemoteSwiftPackageReference section */
737 |
738 | /* Begin XCSwiftPackageProductDependency section */
739 | 1A78A3142BF75FEF00D39199 /* KMPObservableViewModelCore */ = {
740 | isa = XCSwiftPackageProductDependency;
741 | package = 1A78A3112BF75E1F00D39199 /* XCRemoteSwiftPackageReference "KMP-ObservableViewModel" */;
742 | productName = KMPObservableViewModelCore;
743 | };
744 | 1A78A3162BF75FF400D39199 /* KMPObservableViewModelSwiftUI */ = {
745 | isa = XCSwiftPackageProductDependency;
746 | package = 1A78A3112BF75E1F00D39199 /* XCRemoteSwiftPackageReference "KMP-ObservableViewModel" */;
747 | productName = KMPObservableViewModelSwiftUI;
748 | };
749 | /* End XCSwiftPackageProductDependency section */
750 | };
751 | rootObject = 7555FF73242A565900829871 /* Project object */;
752 | }
753 |
--------------------------------------------------------------------------------