Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
joreilly committed Dec 22, 2020
1 parent 556057c commit 298bb90
Show file tree
Hide file tree
Showing 54 changed files with 2,229 additions and 0 deletions.
33 changes: 33 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@

# Ignore Gradle GUI config
gradle-app.setting

# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
!gradle-wrapper.jar

# Cache of project
.gradletasknamecache

# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898
# gradle/wrapper/gradle-wrapper.properties

.idea
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures


*.xcworkspacedata
*.xcuserstate
*.xcscheme
xcschememanagement.plist
*.xcbkptlist
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,37 @@
# MortyComposeKMM

Rick & Morty app to demonstrate use of GraphQL + Jetpack Compose (heavily based on https://github.com/Dimillian/MortyUI).
This is also a Kotlin Multiplatform project with GraphQL code in shared module (making use of [Apollo library's Kotlin Multiplatform support](https://www.apollographql.com/docs/android/essentials/get-started-multiplatform/)).
Right now app shows list of characters and episodes but plan is to update to show detail screens as well.


![BikeShare Screenshot](/art/characters_screenshot.png?raw=true)

The project also makes use of Jetpack Compose's [Paging library](https://developer.android.com/jetpack/androidx/releases/paging#paging_compose_version_100_2)
that allows setting up `LazyColumn` for example that's driven from `PagingSource` as shown below (that source in our case invokes Apollo GraphQL queries).

```kotlin
class CharacterListsViewModel(private val repository: MortyRepository): ViewModel() {

val characters: Flow<PagingData<GetCharactersQuery.Result>> = Pager(PagingConfig(pageSize = 20)) {
CharactersDataSource(repository)
}.flow

}

@Composable
fun CharactersListView() {
val characterListsViewModel = getViewModel<CharacterListsViewModel>()
val lazyCharacterList = characterListsViewModel.characters.collectAsLazyPagingItems()

LazyColumn {
items(lazyCharacterList) { character ->
character?.let {
CharactersListRowView(character)
}
}
}
}
```


2 changes: 2 additions & 0 deletions androidApp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/build
*.iml
83 changes: 83 additions & 0 deletions androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
plugins {
id("com.android.application")
kotlin("android")
}

android {
compileSdkVersion(AndroidSdk.compile)
defaultConfig {
applicationId = "dev.johnoreilly.mortyuicomposekmp"
minSdkVersion(AndroidSdk.min)
targetSdkVersion(AndroidSdk.target)

versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

buildFeatures {
compose = true
}

composeOptions {
kotlinCompilerVersion = "1.4.21"
kotlinCompilerExtensionVersion = Versions.compose
}

buildTypes {
getByName("release") {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}

kotlinOptions {
jvmTarget = "1.8"
}
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs = listOf("-Xallow-jvm-ir-dependencies", "-Xskip-prerelease-check",
"-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi"
)
}
}

dependencies {
implementation("androidx.appcompat:appcompat:1.2.0")
implementation("com.google.android.material:material:1.2.1")
implementation("androidx.lifecycle:lifecycle-extensions:2.2.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.2.0")

implementation(Compose.ui)
implementation(Compose.uiGraphics)
implementation(Compose.uiTooling)
implementation(Compose.foundationLayout)
implementation(Compose.material)
implementation(Compose.runtimeLiveData)
implementation(Compose.navigation)
implementation(Compose.paging)
implementation(Compose.accompanist)

implementation(Koin.core)
implementation(Koin.android)
implementation(Koin.androidViewModel)
implementation(Koin.compose)


testImplementation("junit:junit:4.13.1")
testImplementation("androidx.test:core:1.3.0")
testImplementation("org.robolectric:robolectric:4.4")
androidTestImplementation("androidx.test:runner:1.3.0")

implementation(project(":shared"))
}
18 changes: 18 additions & 0 deletions androidApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="dev.johnoreilly.mortyuicomposekmp">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:name=".MortyComposeKMMApplication"
android:allowBackup="false"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".ui.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package dev.johnoreilly.mortyuicomposekmp

import android.app.Application
import dev.johnoreilly.mortyuicomposekmp.di.appModule
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin

class MortyComposeKMMApplication : Application() {

override fun onCreate() {
super.onCreate()

startKoin {
androidContext(this@MortyComposeKMMApplication)
modules(appModule)
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package dev.johnoreilly.mortyuicomposekmp.di

import dev.johnoreilly.mortyuicomposekmp.shared.MortyRepository
import dev.johnoreilly.mortyuicomposekmp.ui.characters.CharacterListsViewModel
import dev.johnoreilly.mortyuicomposekmp.ui.episodes.EpisodesListViewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module


val mortyAppModule = module {
viewModel { CharacterListsViewModel(get()) }
viewModel { EpisodesListViewModel(get()) }

single { MortyRepository() }
}


// Gather all app modules
val appModule = listOf(mortyAppModule)
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package dev.johnoreilly.mortyuicomposekmp.ui

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Face
import androidx.compose.material.icons.filled.Person
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.setContent
import androidx.navigation.NavHostController
import androidx.navigation.compose.*
import dev.johnoreilly.mortyuicomposekmp.ui.characters.CharacterListsViewModel
import dev.johnoreilly.mortyuicomposekmp.ui.characters.CharactersListView
import dev.johnoreilly.mortyuicomposekmp.ui.episodes.EpisodesListView

class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

setContent {
MaterialTheme {
MainLayout()
}
}
}
}

sealed class BottomNavigationScreens(val route: String, val label: String, val icon: ImageVector) {
object CharactersScreen : BottomNavigationScreens("Characters", "Characters", Icons.Default.Person)
object EpisodesScreen : BottomNavigationScreens("Episodes", "Episodes", Icons.Default.Face)
}

@Composable
fun MainLayout() {
val navController = rememberNavController()
var title by remember { mutableStateOf("") }

val bottomNavigationItems = listOf(
BottomNavigationScreens.CharactersScreen,
BottomNavigationScreens.EpisodesScreen
)

Scaffold(
topBar = {
TopAppBar(title = { Text(title) })
},
bodyContent = {
NavHost(navController, startDestination = BottomNavigationScreens.CharactersScreen.route) {
composable(BottomNavigationScreens.CharactersScreen.route) {
title = BottomNavigationScreens.CharactersScreen.label
CharactersListView()
}
composable(BottomNavigationScreens.EpisodesScreen.route) {
title = BottomNavigationScreens.EpisodesScreen.label
EpisodesListView()
}
}
},
bottomBar = {
BottomNavigation {
val currentRoute = currentRoute(navController)
bottomNavigationItems.forEach { screen ->
BottomNavigationItem(
icon = { Icon(screen.icon) },
label = { Text(screen.label) },
selected = currentRoute == screen.route,
alwaysShowLabels = false, // This hides the title for the unselected items
onClick = {
// This if check gives us a "singleTop" behavior where we do not create a
// second instance of the composable if we are already on that destination
if (currentRoute != screen.route) {
navController.navigate(screen.route)
}
}
)
}
}
}
)
}

@Composable
private fun currentRoute(navController: NavHostController): String? {
val navBackStackEntry by navController.currentBackStackEntryAsState()
return navBackStackEntry?.arguments?.getString(KEY_ROUTE)
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package dev.johnoreilly.mortyuicomposekmp.ui.characters

import androidx.lifecycle.ViewModel
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import dev.johnoreilly.mortyuicomposekmp.GetCharactersQuery
import dev.johnoreilly.mortyuicomposekmp.shared.MortyRepository
import kotlinx.coroutines.flow.Flow

class CharacterListsViewModel(private val repository: MortyRepository): ViewModel() {

val characters: Flow<PagingData<GetCharactersQuery.Result>> = Pager(PagingConfig(pageSize = 20)) {
CharactersDataSource(repository)
}.flow


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package dev.johnoreilly.mortyuicomposekmp.ui.characters

import androidx.paging.PagingSource
import dev.johnoreilly.mortyuicomposekmp.GetCharactersQuery
import dev.johnoreilly.mortyuicomposekmp.shared.MortyRepository

class CharactersDataSource(private val repository: MortyRepository) : PagingSource<Int, GetCharactersQuery.Result>() {

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, GetCharactersQuery.Result> {
val pageNumber = params.key ?: 0

val charactersResponse = repository.getCharacters(pageNumber)
val characters = charactersResponse.data?.characters?.resultsFilterNotNull()

val prevKey = if (pageNumber > 0) pageNumber - 1 else null
val nextKey = charactersResponse.data?.characters?.info?.next
return LoadResult.Page(data = characters ?: emptyList(), prevKey = prevKey, nextKey = nextKey)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package dev.johnoreilly.mortyuicomposekmp.ui.characters

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Card
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import dev.chrisbanes.accompanist.coil.CoilImage
import dev.johnoreilly.mortyuicomposekmp.GetCharactersQuery

@Composable
fun CharactersListRowView(character: GetCharactersQuery.Result) {

Row(modifier = Modifier.fillMaxWidth().clickable(onClick = { }).padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {

val imageUrl = character.image
if (imageUrl != null) {
Card(modifier = Modifier.preferredSize(50.dp), shape = CircleShape) {
CoilImage(data = imageUrl)
}
} else {
Spacer(modifier = Modifier.preferredSize(50.dp))
}

Column(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
Text(character.name ?: "", style = MaterialTheme.typography.h6)
Text("${character.episode?.size ?: 0} episode(s)",
style = MaterialTheme.typography.subtitle2, color = Color.Gray)
}
}
Divider()
}

Loading

0 comments on commit 298bb90

Please sign in to comment.