├── 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 | ![kotlin-version](https://img.shields.io/badge/kotlin-2.2.0-blue?logo=kotlin) 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 | Screenshot 2025-08-10 at 19 52 25 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 | --------------------------------------------------------------------------------