├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── nerdery │ │ └── rtaza │ │ └── jobtracker │ │ ├── data │ │ ├── core │ │ │ ├── Error.kt │ │ │ ├── Resource.kt │ │ │ └── Status.kt │ │ ├── local │ │ │ ├── ApplicationDatabase.kt │ │ │ ├── dao │ │ │ │ ├── JobDao.kt │ │ │ │ ├── JobLocalDataSource.kt │ │ │ │ ├── WorkerDao.kt │ │ │ │ └── WorkerLocalDataSource.kt │ │ │ └── model │ │ │ │ ├── Customer.kt │ │ │ │ ├── Job.kt │ │ │ │ ├── JobStatus.kt │ │ │ │ ├── JobTask.kt │ │ │ │ ├── JobWithRelations.kt │ │ │ │ ├── Location.kt │ │ │ │ └── Worker.kt │ │ ├── remote │ │ │ ├── HttpConstants.kt │ │ │ ├── adapter │ │ │ │ └── RxErrorHandlingCallAdapterFactory.kt │ │ │ ├── api │ │ │ │ └── JobWebApi.kt │ │ │ └── model │ │ │ │ ├── CustomerResponse.kt │ │ │ │ ├── JobResponse.kt │ │ │ │ ├── LocationResponse.kt │ │ │ │ └── WorkerResponse.kt │ │ ├── repository │ │ │ ├── JobRepository.kt │ │ │ ├── NetworkCall.kt │ │ │ ├── NetworkResourceCall.kt │ │ │ └── PersistableNetworkResourceCall.kt │ │ └── util │ │ │ └── JobResponseFactory.kt │ │ ├── di │ │ ├── ActivityScope.kt │ │ ├── ViewModelFactory.kt │ │ ├── ViewModelKey.kt │ │ ├── component │ │ │ └── ApplicationComponent.kt │ │ └── module │ │ │ ├── AdapterModule.kt │ │ │ ├── ApplicationModule.kt │ │ │ ├── HttpModule.kt │ │ │ ├── PersistenceModule.kt │ │ │ └── ViewModelModule.kt │ │ ├── log │ │ └── LogUtil.kt │ │ └── ui │ │ ├── JobTrackerApplication.kt │ │ ├── core │ │ ├── BaseActivity.kt │ │ ├── BaseViewModel.kt │ │ └── SingleLiveEvent.kt │ │ ├── jobs │ │ ├── JobViewHolder.kt │ │ ├── JobsActivity.kt │ │ ├── JobsListAdapter.kt │ │ └── JobsViewModel.kt │ │ ├── util │ │ ├── CollectionExtensions.kt │ │ ├── ContextExtensions.kt │ │ ├── JobIconUtil.kt │ │ ├── LiveDataExtensions.kt │ │ ├── StringExtensions.kt │ │ ├── TextFormatter.kt │ │ ├── TimeUtil.kt │ │ ├── ViewExtensions.kt │ │ └── Visibility.kt │ │ └── view │ │ └── OffsetItemDecoration.kt │ └── res │ ├── drawable-v24 │ └── foreground_launch.xml │ ├── drawable │ ├── background_launch.xml │ ├── icon_background_job_status.xml │ ├── icon_carpentry.xml │ ├── icon_electrical.xml │ ├── icon_error_client_io.xml │ ├── icon_error_server.xml │ ├── icon_error_uauthorized.xml │ ├── icon_error_unknown.xml │ ├── icon_hvac.xml │ ├── icon_no_active_jobs.xml │ ├── icon_paint.xml │ └── icon_plumbing.xml │ ├── layout │ ├── activity_jobs.xml │ └── item_job.xml │ ├── mipmap-anydpi-v26 │ ├── icon_launcher.xml │ └── icon_launcher_round.xml │ ├── mipmap-hdpi │ ├── icon_launcher.png │ └── icon_launcher_round.png │ ├── mipmap-mdpi │ ├── icon_launcher.png │ └── icon_launcher_round.png │ ├── mipmap-xhdpi │ ├── icon_launcher.png │ └── icon_launcher_round.png │ ├── mipmap-xxhdpi │ ├── icon_launcher.png │ └── icon_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── icon_launcher.png │ └── icon_launcher_round.png │ └── values │ ├── colors.xml │ ├── dimens.xml │ ├── strings.xml │ ├── styles.xml │ └── themes.xml ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── schemas └── com.nerdery.rtaza.jobtracker.data.local.ApplicationDatabase │ └── 3.json └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.ap_ 4 | 5 | # Files for the ART/Dalvik VM 6 | *.dex 7 | 8 | # Java class files 9 | *.class 10 | 11 | # Generated files 12 | bin/ 13 | gen/ 14 | out/ 15 | 16 | # Gradle files 17 | .gradle/ 18 | build/ 19 | 20 | # Local configuration file (sdk path, etc) 21 | local.properties 22 | 23 | # Proguard folder generated by Eclipse 24 | proguard/ 25 | 26 | # Log Files 27 | *.log 28 | 29 | # Android Studio Navigation editor temp files 30 | .navigation/ 31 | 32 | # Android Studio captures folder 33 | captures/ 34 | 35 | # Intellij 36 | *.iml 37 | .idea/ 38 | 39 | # Keystore files 40 | *.jks 41 | 42 | # External native build folder generated in Android Studio 2.2 and later 43 | .externalNativeBuild 44 | 45 | # Google Services (e.g. APIs or Firebase) 46 | google-services.json 47 | 48 | # Freeline 49 | freeline.py 50 | freeline/ 51 | freeline_project_description.json 52 | 53 | # OS-specific 54 | .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JobTracker 2 | [Medium article](https://medium.com/@rtaza/android-mvvm-with-network-status-observables-ft-livedata-rxjava-room-listadapter-and-kotlin-a54c328c707b) 3 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: "com.android.application" 2 | apply plugin: "kotlin-android" 3 | apply plugin: "kotlin-android-extensions" 4 | apply plugin: "kotlin-kapt" 5 | 6 | android { 7 | compileSdkVersion 28 8 | defaultConfig { 9 | applicationId "com.nerdery.rtaza.jobtracker" 10 | minSdkVersion 21 11 | targetSdkVersion 28 12 | versionCode 1 13 | versionName "1.0" 14 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 15 | } 16 | buildTypes { 17 | release { 18 | minifyEnabled false 19 | proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" 20 | } 21 | } 22 | flavorDimensions "default" 23 | productFlavors { 24 | sandbox { 25 | buildConfigField 'String', 'API_BASE_URL', '"https://demo/"' 26 | buildConfigField 'String', 'ENVIRONMENT', '"sandbox"' 27 | } 28 | } 29 | defaultConfig { 30 | kapt { 31 | arguments { 32 | arg("room.schemaLocation", "$projectDir/../schemas".toString()) 33 | } 34 | } 35 | } 36 | } 37 | 38 | dependencies { 39 | implementation fileTree(include: ["*.jar"], dir: "libs") 40 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" 41 | implementation "androidx.appcompat:appcompat:$appCompatVersion" 42 | implementation "androidx.recyclerview:recyclerview:$recyclerViewVersion" 43 | implementation "androidx.cardview:cardview:$cardViewVersion" 44 | implementation "com.google.android.material:material:$materialVersion" 45 | implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion" 46 | implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion" 47 | kapt "androidx.lifecycle:lifecycle-compiler:$lifecycleVersion" 48 | implementation "androidx.room:room-runtime:$roomVersion" 49 | kapt "androidx.room:room-compiler:$roomVersion" 50 | implementation "androidx.room:room-rxjava2:$roomVersion" 51 | implementation "com.squareup.okhttp3:logging-interceptor:$okHttpVersion" 52 | implementation "com.squareup.okhttp3:okhttp:$okHttpVersion" 53 | implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" 54 | implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofitVersion" 55 | implementation "com.squareup.retrofit2:converter-moshi:$retrofitVersion" 56 | implementation "com.squareup.moshi:moshi-kotlin:$moshiVersion" 57 | implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion" 58 | implementation "io.reactivex.rxjava2:rxkotlin:$rxKotlinVersion" 59 | implementation "com.google.dagger:dagger:$daggerVersion" 60 | kapt "com.google.dagger:dagger-compiler:$daggerVersion" 61 | implementation "com.google.dagger:dagger-android:$daggerVersion" 62 | implementation "com.google.dagger:dagger-android-support:$daggerVersion" 63 | kapt "com.google.dagger:dagger-android-processor:$daggerVersion" 64 | implementation "com.jakewharton.timber:timber:$timberVersion" 65 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 15 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/core/Error.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.core 2 | 3 | import android.content.Context 4 | import androidx.annotation.DrawableRes 5 | import androidx.annotation.StringRes 6 | import com.nerdery.rtaza.jobtracker.R 7 | import java.net.HttpURLConnection 8 | 9 | const val CODE_UNKNOWN = -1 10 | const val CODE_CLIENT_IO = 50 11 | 12 | /** 13 | * Classification of all errors known to the application. 14 | */ 15 | sealed class Error( 16 | @DrawableRes val iconResourceId: Int, 17 | @StringRes val titleResourceId: Int, 18 | @StringRes val descriptionResourceId: Int, 19 | val code: Int, 20 | throwable: Throwable? 21 | ) : kotlin.Exception(throwable?.message, throwable) { 22 | 23 | open fun getFullErrorMessage(context: Context): String = context.getString( 24 | R.string.format_error_title_description, 25 | context.getString(titleResourceId), 26 | context.getString(descriptionResourceId) 27 | ) 28 | 29 | class HttpClientIo(throwable: Throwable) : 30 | Error( 31 | R.drawable.icon_error_client_io, 32 | R.string.error_title_http_io, 33 | R.string.error_description_http_io, 34 | CODE_CLIENT_IO, 35 | throwable 36 | ) 37 | 38 | class HttpServer(throwable: Throwable) : 39 | Error( 40 | R.drawable.icon_error_server, 41 | R.string.error_title_http_server, 42 | R.string.error_description_http_server, 43 | HttpURLConnection.HTTP_INTERNAL_ERROR, 44 | throwable 45 | ) 46 | 47 | class HttpUnauthorized(throwable: Throwable) : 48 | Error( 49 | R.drawable.icon_error_uauthorized, 50 | R.string.error_title_http_unauthorized, 51 | R.string.error_description_http_unauthorized, 52 | HttpURLConnection.HTTP_UNAUTHORIZED, 53 | throwable 54 | ) 55 | 56 | class Unknown(throwable: Throwable? = null) : 57 | Error( 58 | R.drawable.icon_error_unknown, 59 | R.string.error_title_unknown, 60 | R.string.error_description_unknown, 61 | CODE_UNKNOWN, 62 | throwable 63 | ) { 64 | 65 | override fun getFullErrorMessage(context: Context): String = context.getString(R.string.error_title_unknown) 66 | } 67 | 68 | companion object { 69 | 70 | /** 71 | * Converts an error [code] to an [Error] instance. 72 | */ 73 | fun getFromCode(code: Int, throwable: Throwable): Error { 74 | return when (code) { 75 | CODE_CLIENT_IO -> HttpClientIo(throwable) 76 | HttpURLConnection.HTTP_INTERNAL_ERROR -> HttpServer(throwable) 77 | HttpURLConnection.HTTP_UNAUTHORIZED -> HttpUnauthorized(throwable) 78 | else -> Unknown(throwable) 79 | } 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/core/Resource.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.core 2 | 3 | /** 4 | * A wrapper for data that is requested from a data source. 5 | * 6 | * @param status the current status ([Status.LOADING], [Status.RESOURCE_FOUND], [Status.NO_RESOURCE_FOUND], or 7 | * [Status.ERROR]) of the Resource at any given time. 8 | * @param data the object that the Resource wraps. [Status.LOADING] and [Status.RESOURCE_FOUND] could each have different 9 | * representations of the data. 10 | * @param error the [com.nerdery.rtaza.jobtracker.data.core.Error] that the Resource wraps in the case that the Resource 11 | * has a status of [Status.ERROR]. 12 | */ 13 | sealed class Resource( 14 | val status: Status, 15 | val data: DataType?, 16 | val error: com.nerdery.rtaza.jobtracker.data.core.Error? 17 | ) { 18 | 19 | class Loading(loadingData: DataType?) : Resource(Status.LOADING, loadingData, null) 20 | 21 | class ResourceFound(successData: DataType) : Resource(Status.RESOURCE_FOUND, successData, null) 22 | 23 | class NoResourceFound : Resource(Status.NO_RESOURCE_FOUND, null, null) 24 | 25 | class Error(error: com.nerdery.rtaza.jobtracker.data.core.Error) : 26 | Resource(Status.ERROR, null, error) 27 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/core/Status.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.core 2 | 3 | /** 4 | * Status of a network request to get a [Resource]. 5 | */ 6 | enum class Status { 7 | /** 8 | * The [Resource] is being loaded from a source. 9 | */ 10 | LOADING, 11 | /** 12 | * The network request was successful, and the result is not null or empty. 13 | */ 14 | RESOURCE_FOUND, 15 | /** 16 | * The network request was successful, but the result was null or empty. 17 | */ 18 | NO_RESOURCE_FOUND, 19 | /** 20 | * The network request failed with an error. 21 | */ 22 | ERROR 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/local/ApplicationDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.local 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import androidx.room.TypeConverters 6 | import com.nerdery.rtaza.jobtracker.data.local.dao.JobDao 7 | import com.nerdery.rtaza.jobtracker.data.local.dao.WorkerDao 8 | import com.nerdery.rtaza.jobtracker.data.local.model.Job 9 | import com.nerdery.rtaza.jobtracker.data.local.model.JobStatus 10 | import com.nerdery.rtaza.jobtracker.data.local.model.JobTask 11 | import com.nerdery.rtaza.jobtracker.data.local.model.Worker 12 | 13 | @Database( 14 | entities = [ 15 | Job::class, 16 | Worker::class 17 | ], version = 3 18 | ) 19 | @TypeConverters( 20 | JobStatus.Converter::class, 21 | JobTask.Converter::class 22 | ) 23 | abstract class ApplicationDatabase : RoomDatabase() { 24 | 25 | abstract fun jobDao(): JobDao 26 | 27 | abstract fun workerDao(): WorkerDao 28 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/local/dao/JobDao.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.local.dao 2 | 3 | import androidx.room.* 4 | import com.nerdery.rtaza.jobtracker.data.local.model.Job 5 | import com.nerdery.rtaza.jobtracker.data.local.model.JobWithRelations 6 | import io.reactivex.Flowable 7 | import io.reactivex.Maybe 8 | 9 | private const val QUERY_SELECT_ALL = """ 10 | SELECT job.*, worker.id as worker_id, worker.firstName as worker_firstName, worker.lastName as worker_lastName 11 | FROM job INNER JOIN worker ON job.workerId = worker.id 12 | WHERE active = :active 13 | ORDER BY status DESC, eta ASC""" 14 | 15 | private const val QUERY_JOB_ID = """ 16 | SELECT job.*, worker.id as worker_id, worker.firstName as worker_firstName, worker.lastName as worker_lastName 17 | FROM job INNER JOIN worker ON job.workerId = worker.id 18 | WHERE job.id = :jobId""" 19 | 20 | /** 21 | * Data Access Object for defining Room database operations that can be performed on [Job] entities and entities related 22 | * to [Job]s, such as a [Job]'s assigned [com.nerdery.rtaza.jobtracker.data.local.model.Worker]. 23 | */ 24 | @Dao 25 | abstract class JobDao : JobLocalDataSource { 26 | 27 | @Insert(onConflict = OnConflictStrategy.REPLACE) 28 | abstract override fun insert(job: Job) 29 | 30 | @Insert(onConflict = OnConflictStrategy.REPLACE) 31 | abstract override fun insertAll(jobs: List) 32 | 33 | @Query(value = QUERY_JOB_ID) 34 | // Only queries the fields from worker that are pertinent to job-level details 35 | @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) 36 | abstract override fun load(jobId: Long): Maybe 37 | 38 | @Query(value = QUERY_SELECT_ALL) 39 | // Only queries the fields from Worker that are pertinent to job-level details 40 | @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) 41 | abstract override fun loadAll(active: Boolean): Maybe> 42 | 43 | override fun stream(jobId: Long): Flowable { 44 | return streamInternal(jobId).distinctUntilChanged() 45 | } 46 | 47 | @Query(value = QUERY_JOB_ID) 48 | // Only queries the fields from Worker that are pertinent to job-level details 49 | @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) 50 | protected abstract fun streamInternal(jobId: Long): Flowable 51 | 52 | override fun streamAll(active: Boolean): Flowable> { 53 | return streamAllInternal(active).distinctUntilChanged() 54 | } 55 | 56 | @Query(value = QUERY_SELECT_ALL) 57 | // Only queries the fields from worker that are pertinent to job-level details 58 | @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) 59 | protected abstract fun streamAllInternal(active: Boolean): Flowable> 60 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/local/dao/JobLocalDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.local.dao 2 | 3 | import com.nerdery.rtaza.jobtracker.data.local.model.Job 4 | import com.nerdery.rtaza.jobtracker.data.local.model.JobWithRelations 5 | import io.reactivex.Flowable 6 | import io.reactivex.Maybe 7 | 8 | interface JobLocalDataSource { 9 | 10 | fun insert(job: Job) 11 | 12 | fun insertAll(jobs: List) 13 | 14 | fun load(jobId: Long): Maybe 15 | 16 | fun loadAll(active: Boolean): Maybe> 17 | 18 | fun stream(jobId: Long): Flowable 19 | 20 | fun streamAll(active: Boolean): Flowable> 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/local/dao/WorkerDao.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.local.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import com.nerdery.rtaza.jobtracker.data.local.model.Worker 8 | import io.reactivex.Flowable 9 | import io.reactivex.Maybe 10 | 11 | private const val QUERY_SELECT_ALL = "SELECT * FROM worker WHERE id = :workerId LIMIT 1" 12 | 13 | /** 14 | * Data Access Object for defining Room database operations that can be performed on [Worker] entities. 15 | */ 16 | @Dao 17 | abstract class WorkerDao : WorkerLocalDataSource { 18 | 19 | @Insert(onConflict = OnConflictStrategy.REPLACE) 20 | abstract override fun insert(worker: Worker) 21 | 22 | @Insert(onConflict = OnConflictStrategy.REPLACE) 23 | abstract override fun insertAll(workers: List) 24 | 25 | @Query(QUERY_SELECT_ALL) 26 | abstract override fun load(workerId: String): Maybe 27 | 28 | override fun stream(workerId: String): Flowable> { 29 | return streamInternal(workerId).distinctUntilChanged() 30 | } 31 | 32 | @Query(QUERY_SELECT_ALL) 33 | protected abstract fun streamInternal(workerId: String): Flowable> 34 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/local/dao/WorkerLocalDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.local.dao 2 | 3 | import com.nerdery.rtaza.jobtracker.data.local.model.Worker 4 | import io.reactivex.Flowable 5 | import io.reactivex.Maybe 6 | 7 | interface WorkerLocalDataSource { 8 | 9 | fun insert(worker: Worker) 10 | 11 | fun insertAll(workers: List) 12 | 13 | fun load(workerId: String): Maybe 14 | 15 | fun stream(workerId: String): Flowable> 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/local/model/Customer.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.local.model 2 | 3 | data class Customer( 4 | val firstName: String, 5 | val lastName: String, 6 | val phoneNumber: String 7 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/local/model/Job.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.local.model 2 | 3 | import androidx.room.* 4 | 5 | @Entity( 6 | foreignKeys = [(ForeignKey( 7 | entity = Worker::class, 8 | parentColumns = arrayOf("id"), 9 | childColumns = arrayOf("workerId") 10 | ))], 11 | indices = [(Index("workerId"))] 12 | ) 13 | data class Job( 14 | @PrimaryKey var id: Long, 15 | var task: JobTask, 16 | private var status: JobStatus, 17 | private var active: Boolean, 18 | @Embedded var customer: Customer, 19 | @Embedded var location: Location, 20 | var workerId: Long?, 21 | var eta: Long, 22 | var notes: String? 23 | ) { 24 | 25 | fun setStatus(status: JobStatus) { 26 | this.status = status 27 | active = status.active 28 | } 29 | 30 | fun getStatus() = status 31 | 32 | fun getActive() = active 33 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/local/model/JobStatus.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.local.model 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.room.TypeConverter 5 | import com.nerdery.rtaza.jobtracker.R 6 | 7 | /** 8 | * The status of a job. The order of the enum values are significant. The ordinal of each value corresponds to the 9 | * priority of the job. The smaller the ordinal, the lower the priority. 10 | */ 11 | enum class JobStatus( 12 | val id: String, 13 | @StringRes val label: Int, 14 | val active: Boolean 15 | ) { 16 | NEW("new", R.string.job_status_new, false), 17 | ACCEPTED("accepted", R.string.job_status_accepted, true), 18 | DECLINED("declined", R.string.job_status_declined, false), 19 | MISSED("missed", R.string.job_status_missed, false), 20 | EN_ROUTE("enRoute", R.string.job_status_en_route, true), 21 | ON_SITE("onSite", R.string.job_status_on_site, true), 22 | COMPLETED("completed", R.string.job_status_completed, false); 23 | 24 | class Converter { 25 | 26 | fun toJobStatus(id: String): JobStatus { 27 | var status = NEW 28 | 29 | for (value in JobStatus.values()) { 30 | if (value.id == id) { 31 | status = value 32 | } 33 | } 34 | 35 | return status 36 | } 37 | 38 | @TypeConverter 39 | fun toJobStatus(ordinal: Int): JobStatus { 40 | return values()[ordinal] 41 | } 42 | 43 | @TypeConverter 44 | fun toOrdinal(status: JobStatus): Int { 45 | return status.ordinal 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/local/model/JobTask.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.local.model 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.room.TypeConverter 5 | import com.nerdery.rtaza.jobtracker.R 6 | 7 | enum class JobTask( 8 | val id: String, 9 | @StringRes val label: Int 10 | ) { 11 | ELECTRICAL("electrical", R.string.job_task_electrical), 12 | HVAC("hvac", R.string.job_task_hvac), 13 | PLUMBING("plumbing", R.string.job_task_plumbing), 14 | CARPENTRY("carpentry", R.string.job_task_carpentry), 15 | PAINT("paint", R.string.job_task_paint); 16 | 17 | class Converter { 18 | 19 | fun toJobTask(id: String): JobTask { 20 | var task = PAINT 21 | 22 | for (value in JobTask.values()) { 23 | if (value.id == id) { 24 | task = value 25 | } 26 | } 27 | 28 | return task 29 | } 30 | 31 | @TypeConverter 32 | fun toJobTask(ordinal: Int): JobTask { 33 | return values()[ordinal] 34 | } 35 | 36 | @TypeConverter 37 | fun toOrdinal(task: JobTask): Int { 38 | return task.ordinal 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/local/model/JobWithRelations.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.local.model 2 | 3 | import androidx.room.Embedded 4 | 5 | data class JobWithRelations( 6 | @Embedded var job: Job, 7 | @Embedded(prefix = "worker_") var worker: WorkerRelation? 8 | ) 9 | 10 | data class WorkerRelation( 11 | val id: Long, 12 | val firstName: String, 13 | val lastName: String 14 | ) { 15 | constructor(worker: Worker) : this(worker.id, worker.firstName, worker.lastName) 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/local/model/Location.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.local.model 2 | 3 | data class Location( 4 | val street: String, 5 | val city: String, 6 | val zip: String, 7 | val state: String, 8 | val latitude: Double, 9 | val longitude: Double 10 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/local/model/Worker.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.local.model 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity 7 | data class Worker( 8 | @PrimaryKey var id: Long, 9 | var firstName: String, 10 | var lastName: String, 11 | var online: Boolean 12 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/remote/HttpConstants.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.remote 2 | 3 | import java.util.concurrent.TimeUnit 4 | 5 | class HttpConstants { 6 | 7 | companion object { 8 | const val TIMEOUT_IO: Long = 10_000 9 | val TIMEOUT_TIME_UNIT: TimeUnit = TimeUnit.MILLISECONDS 10 | } 11 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/remote/adapter/RxErrorHandlingCallAdapterFactory.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.remote.adapter 2 | 3 | import com.nerdery.rtaza.jobtracker.data.core.Error 4 | import io.reactivex.* 5 | import retrofit2.Call 6 | import retrofit2.CallAdapter 7 | import retrofit2.HttpException 8 | import retrofit2.Retrofit 9 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 10 | import timber.log.Timber 11 | import java.io.IOException 12 | import java.lang.reflect.Type 13 | import java.net.HttpURLConnection 14 | 15 | /** 16 | * Retrofit CallAdapter.Factory for centrally adapting all Retrofit exceptions to custom exceptions that contain 17 | * app-specific error codes and messages. 18 | */ 19 | class RxErrorHandlingCallAdapterFactory(scheduler: Scheduler) : CallAdapter.Factory() { 20 | private val rxJava2CallAdapterFactory by lazy { 21 | RxJava2CallAdapterFactory.createWithScheduler(scheduler) 22 | } 23 | 24 | override fun get(returnType: Type, annotations: Array, retrofit: Retrofit): CallAdapter<*, *>? { 25 | return RxCallAdapterWrapper( 26 | rxJava2CallAdapterFactory.get(returnType, annotations, retrofit) as CallAdapter 27 | ) 28 | } 29 | 30 | private class RxCallAdapterWrapper(private val rxJava2CallAdapter: CallAdapter) : CallAdapter { 31 | 32 | override fun responseType(): Type { 33 | return rxJava2CallAdapter.responseType() 34 | } 35 | 36 | override fun adapt(call: Call): Any { 37 | val stream = rxJava2CallAdapter.adapt(call) 38 | 39 | return when (stream) { 40 | is Flowable<*> -> stream.onErrorResumeNext { throwable: Throwable -> 41 | Flowable.error(adaptException(throwable)) 42 | } 43 | is Single<*> -> stream.onErrorResumeNext { throwable -> 44 | Single.error(adaptException(throwable)) 45 | } 46 | is Maybe<*> -> stream.onErrorResumeNext { throwable: Throwable -> 47 | Maybe.error(adaptException(throwable)) 48 | } 49 | is Completable -> stream.onErrorResumeNext { throwable: Throwable -> 50 | Completable.error(adaptException(throwable)) 51 | } 52 | is Observable<*> -> stream.onErrorResumeNext { throwable: Throwable -> 53 | Observable.error(adaptException(throwable)) 54 | } 55 | else -> { 56 | Timber.wtf("Unknown RxJava stream type") 57 | stream 58 | } 59 | } 60 | } 61 | 62 | private fun adaptException(throwable: Throwable): Error { 63 | Timber.e(throwable) 64 | 65 | return when (throwable) { 66 | is IOException -> 67 | // Network error (no network connection, timeout) 68 | Error.HttpClientIo(throwable) 69 | is HttpException -> 70 | // HTTP error (server down, unauthorized request) 71 | when (throwable.code()) { 72 | HttpURLConnection.HTTP_INTERNAL_ERROR -> Error.HttpServer(throwable) 73 | HttpURLConnection.HTTP_UNAUTHORIZED -> Error.HttpUnauthorized(throwable) 74 | else -> Error.Unknown(throwable) 75 | } 76 | else -> 77 | // Unknown error 78 | Error.Unknown(throwable) 79 | } 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/remote/api/JobWebApi.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.remote.api 2 | 3 | import com.nerdery.rtaza.jobtracker.data.remote.model.JobResponse 4 | import io.reactivex.Single 5 | import retrofit2.http.GET 6 | import retrofit2.http.Query 7 | 8 | interface JobWebApi { 9 | 10 | @GET("job") 11 | fun fetch(@Query("jobId") jobId: Long): Single 12 | 13 | @GET("jobs") 14 | fun fetchAll(@Query("active") active: Boolean): Single> 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/remote/model/CustomerResponse.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.remote.model 2 | 3 | import com.nerdery.rtaza.jobtracker.data.local.model.Customer 4 | 5 | data class CustomerResponse( 6 | val firstName: String, 7 | val lastName: String, 8 | val phoneNumber: String 9 | ) { 10 | 11 | fun toCustomer(): Customer { 12 | return Customer(firstName, lastName, phoneNumber) 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/remote/model/JobResponse.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.remote.model 2 | 3 | import com.nerdery.rtaza.jobtracker.data.local.model.Job 4 | import com.nerdery.rtaza.jobtracker.data.local.model.JobStatus 5 | import com.nerdery.rtaza.jobtracker.data.local.model.JobTask 6 | 7 | data class JobResponse( 8 | val id: Long, 9 | val task: String, 10 | val status: String, 11 | val locationResponse: LocationResponse, 12 | val workerResponse: WorkerResponse?, 13 | val customerResponse: CustomerResponse, 14 | val eta: Long, 15 | val customerNotes: String? 16 | ) { 17 | 18 | fun toJob(): Job { 19 | val jobStatus = JobStatus.Converter().toJobStatus(status) 20 | return Job( 21 | id, 22 | JobTask.Converter().toJobTask(task), 23 | jobStatus, 24 | jobStatus.active, 25 | customerResponse.toCustomer(), 26 | locationResponse.toLocation(), 27 | workerResponse?.id, 28 | eta, 29 | customerNotes 30 | ) 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/remote/model/LocationResponse.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.remote.model 2 | 3 | import com.nerdery.rtaza.jobtracker.data.local.model.Location 4 | 5 | data class LocationResponse( 6 | val street: String, 7 | val city: String, 8 | val zip: String, 9 | val state: String, 10 | val latitude: Double, 11 | val longitude: Double 12 | ) { 13 | 14 | fun toLocation(): Location { 15 | return Location(street, city, zip, state, latitude, longitude) 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/remote/model/WorkerResponse.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.remote.model 2 | 3 | import com.nerdery.rtaza.jobtracker.data.local.model.Worker 4 | 5 | data class WorkerResponse( 6 | val id: Long, 7 | val firstName: String, 8 | val lastName: String, 9 | val available: Boolean 10 | ) { 11 | 12 | fun toWorker(): Worker { 13 | return Worker(id, firstName, lastName, available) 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/repository/JobRepository.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.repository 2 | 3 | import com.nerdery.rtaza.jobtracker.data.core.Error 4 | import com.nerdery.rtaza.jobtracker.data.core.Resource 5 | import com.nerdery.rtaza.jobtracker.data.local.dao.JobLocalDataSource 6 | import com.nerdery.rtaza.jobtracker.data.local.dao.WorkerLocalDataSource 7 | import com.nerdery.rtaza.jobtracker.data.local.model.JobWithRelations 8 | import com.nerdery.rtaza.jobtracker.data.remote.api.JobWebApi 9 | import com.nerdery.rtaza.jobtracker.data.remote.model.JobResponse 10 | import com.nerdery.rtaza.jobtracker.data.util.JobResponseFactory 11 | import com.nerdery.rtaza.jobtracker.ui.util.isNullOrEmpty 12 | import io.reactivex.Maybe 13 | import io.reactivex.Observable 14 | import io.reactivex.ObservableEmitter 15 | import io.reactivex.Single 16 | import javax.inject.Inject 17 | 18 | class JobRepository @Inject constructor( 19 | private val jobRemoteDataSource: JobWebApi, 20 | private val jobLocalDataSource: JobLocalDataSource, 21 | private val workerLocalDataSource: WorkerLocalDataSource 22 | ) { 23 | 24 | fun getJobs(active: Boolean): Observable>> { 25 | return object : PersistableNetworkResourceCall, List>() { 26 | override fun loadFromDatabase(): Maybe> = jobLocalDataSource.loadAll(active) 27 | 28 | override fun createNetworkCall(): Single> = jobRemoteDataSource.fetchAll(active) 29 | 30 | override fun onNetworkCallSuccess( 31 | emitter: ObservableEmitter>>, 32 | response: List 33 | ) { 34 | handleNetworkResponse(emitter, response, active) 35 | } 36 | 37 | // The network request is expected to fail since the URL that's being hit isn't valid. 38 | // Insert some fake data here for now. 39 | // TODO: Remove this method override after a local or remote data server is set up to return data 40 | override fun onNetworkCallError( 41 | emitter: ObservableEmitter>>, 42 | error: Error 43 | ) { 44 | val response = listOf( 45 | JobResponseFactory.createAcceptedHvacJobResponse(), 46 | JobResponseFactory.createAcceptedElectricalJobResponse(), 47 | JobResponseFactory.createEnRoutePlumbingJobResponse() 48 | ) 49 | 50 | handleNetworkResponse(emitter, response, active) 51 | } 52 | }.resourceObservable 53 | } 54 | 55 | fun handleNetworkResponse( 56 | emitter: ObservableEmitter>>, response: List, 57 | active: Boolean 58 | ) { 59 | if (response.isNullOrEmpty()) { 60 | emitter.onNext(Resource.NoResourceFound()) 61 | } else { 62 | updateLocalDataSources(response) 63 | emitter.setDisposable(jobLocalDataSource.streamAll(active) 64 | .subscribe { jobsWithRelations -> 65 | emitter.onNext(Resource.ResourceFound(jobsWithRelations)) 66 | }) 67 | } 68 | } 69 | 70 | fun getJob(jobId: Long): Observable> { 71 | return object : PersistableNetworkResourceCall() { 72 | override fun loadFromDatabase(): Maybe = jobLocalDataSource.load(jobId) 73 | 74 | override fun createNetworkCall(): Single = jobRemoteDataSource.fetch(jobId) 75 | 76 | override fun onNetworkCallSuccess( 77 | emitter: ObservableEmitter>, 78 | response: JobResponse 79 | ) { 80 | updateLocalDataSources(response) 81 | emitter.setDisposable(jobLocalDataSource.stream(jobId) 82 | .subscribe { jobWithRelations -> 83 | emitter.onNext(Resource.ResourceFound(jobWithRelations)) 84 | }) 85 | } 86 | }.resourceObservable 87 | } 88 | 89 | private fun updateLocalDataSources(jobsResponse: List) { 90 | // Pull out all non-null worker entities assigned to these jobs so they can be inserted into the worker database 91 | val workers = jobsResponse.map { workerResponse -> 92 | workerResponse.workerResponse 93 | }.mapNotNull { workerResponse -> 94 | workerResponse?.toWorker() 95 | } 96 | 97 | workerLocalDataSource.insertAll(workers) 98 | jobLocalDataSource.insertAll(jobsResponse.map { jobResponse -> 99 | jobResponse.toJob() 100 | }) 101 | } 102 | 103 | private fun updateLocalDataSources(jobResponse: JobResponse) { 104 | // Pull out the worker entity assigned to this job (if it's not null) so it can be inserted into the worker database 105 | jobResponse.workerResponse?.let { workerResponse -> 106 | workerLocalDataSource.insert(workerResponse.toWorker()) 107 | } 108 | jobLocalDataSource.insert(jobResponse.toJob()) 109 | } 110 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/repository/NetworkCall.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.repository 2 | 3 | import android.annotation.SuppressLint 4 | import com.nerdery.rtaza.jobtracker.data.core.Error 5 | import io.reactivex.ObservableEmitter 6 | import io.reactivex.Single 7 | import timber.log.Timber 8 | 9 | /** 10 | * Abstracts the logic for making a network call. This class forwards the request's result payload or error to 11 | * [onNetworkCallSuccess] or [onNetworkCallError], respectively. 12 | * 13 | * @param ResponseType type of the response returned from the network call. 14 | * @param EmissionType type expected by the [ObservableEmitter]. 15 | */ 16 | abstract class NetworkCall { 17 | 18 | /** 19 | * Override this method and return the response of the network call. 20 | * 21 | * @return the [Single] from the network call stream. 22 | */ 23 | protected abstract fun createNetworkCall(): Single 24 | 25 | /** 26 | * Override this method to handle the success response of the network call. 27 | * 28 | * @param emitter the [ObservableEmitter] of the calling [io.reactivex.Observable]. This is passed as a param to 29 | * allow subclasses to override the flow of the [io.reactivex.Observable] based on the response of this call. 30 | * @param response the success response of the network call. 31 | */ 32 | protected abstract fun onNetworkCallSuccess(emitter: ObservableEmitter, response: ResponseType) 33 | 34 | /** 35 | * Override this method to handle the error returned from the network call. 36 | * 37 | * @param emitter the [ObservableEmitter] of the calling [io.reactivex.Observable]. This is passed as a param to 38 | * allow subclasses to override the flow of the [io.reactivex.Observable] based on the error from this call. 39 | * @param error the mapped [Error] thrown from the call. 40 | */ 41 | protected abstract fun onNetworkCallError(emitter: ObservableEmitter, error: Error) 42 | 43 | /** 44 | * Performs the network call defined in [createNetworkCall]. 45 | * This method should be called from within an [io.reactivex.Observable]. 46 | * 47 | * @param emitter the [ObservableEmitter] of the calling [io.reactivex.Observable]. 48 | */ 49 | @SuppressLint("CheckResult") // The Single's result is disposed in the onSuccess or onError 50 | protected open fun fetchFromNetwork(emitter: ObservableEmitter) { 51 | createNetworkCall() 52 | .subscribe({ response -> 53 | onNetworkCallSuccess(emitter, response) 54 | }, { error -> 55 | if (error is Error) { 56 | onNetworkCallError(emitter, error) 57 | } else { 58 | // This should never happen since all network exceptions are caught and typecasted in 59 | // RxErrorHandlingCallAdapterFactory 60 | Timber.wtf("fetchFromNetwork() returned unknown error: $error") 61 | } 62 | }) 63 | } 64 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/repository/NetworkResourceCall.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.repository 2 | 3 | import com.nerdery.rtaza.jobtracker.data.core.Error 4 | import com.nerdery.rtaza.jobtracker.data.core.Resource 5 | import io.reactivex.Observable 6 | import io.reactivex.ObservableEmitter 7 | 8 | /** 9 | * Abstracts the logic for making a network call that returns a resource. 10 | * 11 | * Step 1. Emits loading. 12 | * Step 2. Attempts to fetch item from network. 13 | * Step 3. If the network call fails, emits the [Error]. 14 | * 15 | * All operations are executed on the TIMEOUT_IO [io.reactivex.Scheduler]. 16 | * 17 | * @param ResourceType type of the resource to be emitted. 18 | */ 19 | abstract class NetworkResourceCall : NetworkCall>() { 20 | val resourceObservable: Observable> 21 | 22 | init { 23 | resourceObservable = Observable.create> { emitter -> 24 | emitter.onNext(Resource.Loading(null)) 25 | fetchFromNetwork(emitter) 26 | } 27 | } 28 | 29 | /** 30 | * Emits the [error] from the network call. Override this if need to handle the [error] differently. 31 | */ 32 | override fun onNetworkCallError(emitter: ObservableEmitter>, error: Error) { 33 | emitter.onNext(Resource.Error(error)) 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/repository/PersistableNetworkResourceCall.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.repository 2 | 3 | import com.nerdery.rtaza.jobtracker.data.core.Error 4 | import com.nerdery.rtaza.jobtracker.data.core.Resource 5 | import io.reactivex.Maybe 6 | import io.reactivex.Observable 7 | import io.reactivex.ObservableEmitter 8 | import timber.log.Timber 9 | 10 | /** 11 | * Abstracts the logic for making a network call that returns a resource that is persisted as a resource. Note that this 12 | * class handles two different data types because the data type returned from the web API, [ResponseType], may not match 13 | * the data type used in the local database, [ResourceType]. 14 | * 15 | * Step 1. Emits loading. 16 | * Step 2. Attempts to load resource from local database. 17 | * Step 3. If load returns a resource, emits loading with that resource. 18 | * If load completes with an error, continues. 19 | * If load returns no resource, continues. 20 | * Step 4. Attempts to fetch item from network. 21 | * Step 5. If the network call fails, emits the [Error]. 22 | * 23 | * All operations are executed on the TIMEOUT_IO [io.reactivex.Scheduler]. 24 | * 25 | * @param ResponseType type of the response returned from the network call. 26 | * @param ResourceType type of the resource to be stored in the database and to be emitted. 27 | */ 28 | abstract class PersistableNetworkResourceCall : 29 | NetworkCall>() { 30 | val resourceObservable: Observable> 31 | 32 | init { 33 | resourceObservable = Observable.create> { emitter -> 34 | emitter.onNext(Resource.Loading(null)) 35 | loadFromDatabase() 36 | .subscribe({ queryResult -> 37 | emitter.onNext(Resource.Loading(queryResult)) 38 | fetchFromNetwork(emitter) 39 | }, { error -> 40 | Timber.e(error) 41 | fetchFromNetwork(emitter) 42 | }, { 43 | fetchFromNetwork(emitter) 44 | }) 45 | } 46 | } 47 | 48 | /** 49 | * Called to execute a local database query and return the result of that query. 50 | * 51 | * @return the result of the [Maybe] from the database query stream. 52 | */ 53 | protected abstract fun loadFromDatabase(): Maybe 54 | 55 | /** 56 | * Emits the [error] from the network call. Override this if need to handle the [error] differently. 57 | */ 58 | override fun onNetworkCallError(emitter: ObservableEmitter>, error: Error) { 59 | emitter.onNext(Resource.Error(error)) 60 | } 61 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/data/util/JobResponseFactory.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.data.util 2 | 3 | import com.nerdery.rtaza.jobtracker.data.local.model.JobStatus 4 | import com.nerdery.rtaza.jobtracker.data.local.model.JobTask 5 | import com.nerdery.rtaza.jobtracker.data.remote.model.CustomerResponse 6 | import com.nerdery.rtaza.jobtracker.data.remote.model.JobResponse 7 | import com.nerdery.rtaza.jobtracker.data.remote.model.LocationResponse 8 | import com.nerdery.rtaza.jobtracker.data.remote.model.WorkerResponse 9 | import com.nerdery.rtaza.jobtracker.ui.util.TimeUtil 10 | import java.util.concurrent.TimeUnit 11 | 12 | class JobResponseFactory { 13 | 14 | companion object { 15 | 16 | fun createAcceptedHvacJobResponse() = JobResponse( 17 | id = 123456789, 18 | task = JobTask.HVAC.id, 19 | status = JobStatus.ACCEPTED.id, 20 | locationResponse = LocationResponse( 21 | "6501 Railroad Ave", 22 | "Twin Peaks", 23 | "98065", 24 | "WA", 25 | 47.544390, 26 | -121.841370 27 | ), 28 | workerResponse = WorkerResponse(919345446, "Phillip", "Jeffries", true), 29 | customerResponse = CustomerResponse("Dale", "Cooper", "4306665421"), 30 | eta = TimeUtil.getCurrentTimeMillis() + TimeUnit.MINUTES.toMillis(15), 31 | customerNotes = "Diane, 7:30 am, February twenty-fourth. Entering town of Twin Peaks. Five miles south of the Canadian border, twelve miles west of the state line. Never seen so many trees in my life. As W.C. Fields would say, I'd rather be here than Philadelphia. It's fifty-four degrees on a slightly overcast day. Weatherman said rain. If you could get paid that kind of money for being wrong sixty percent of the time it'd beat working. Mileage is 79,345, gauge is on reserve, I'm riding on fumes here, I've got to tank up when I get into town. Remind me to tell you how much that is. Lunch was \$6.31 at the Lamplighter Inn. That's on Highway Two near Lewis Fork. That was a tuna fish sandwich on whole wheat, a slice of cherry pie and a cup of coffee. Damn good food. Diane, if you ever get up this way, that cherry pie is worth a stop." 32 | ) 33 | 34 | fun createAcceptedElectricalJobResponse() = JobResponse( 35 | id = 987654321, 36 | task = JobTask.ELECTRICAL.id, 37 | status = JobStatus.ACCEPTED.id, 38 | locationResponse = LocationResponse( 39 | "137 W North Bend Way", 40 | "Twin Peaks", 41 | "98045", 42 | "WA", 43 | 47.494310, 44 | -121.785130 45 | ), 46 | workerResponse = WorkerResponse(919937023, "Pete", "Martell", true), 47 | customerResponse = CustomerResponse("Laura", "Palmer", "4306652451"), 48 | eta = TimeUtil.getCurrentTimeMillis() + TimeUnit.MINUTES.toMillis(1), 49 | customerNotes = "Nowhere... fast. And you're not coming." 50 | ) 51 | 52 | fun createEnRoutePlumbingJobResponse() = JobResponse( 53 | id = 543216789, 54 | task = JobTask.PLUMBING.id, 55 | status = JobStatus.EN_ROUTE.id, 56 | locationResponse = LocationResponse( 57 | "4200 Preston-Fall City Rd", 58 | "Twin Peaks", 59 | "98066", 60 | "WA", 61 | 47.567310, 62 | -121.887550 63 | ), 64 | workerResponse = WorkerResponse(919012238, "Gordon", "Cole", true), 65 | customerResponse = CustomerResponse("Margaret", "Lanterman", "4306665421"), 66 | eta = TimeUtil.getCurrentTimeMillis() - TimeUnit.MINUTES.toMillis(1), 67 | customerNotes = "A log is a portion of a tree. (Turning end of log to camera.) At the end of a crosscut log — many of you know this — there are rings. Each ring represents one year in the life of the tree. How long it takes to a grow a tree! I don't mind telling you some things. Many things I, I musn't say. Just notice that my fireplace is boarded up. There will never be a fire there. On the mantelpiece, in that jar, are some of the ashes of my husband. My log hears things I cannot hear. But my log tells me about the sounds, about the new words. Even though it has stopped growing larger, my log is aware." 68 | ) 69 | } 70 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/di/ActivityScope.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.di 2 | 3 | import javax.inject.Scope 4 | 5 | @Scope 6 | @Retention(AnnotationRetention.RUNTIME) 7 | annotation class ActivityScope -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/di/ViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.di 2 | 3 | import android.app.Application 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.ViewModelProvider 6 | import javax.inject.Inject 7 | import javax.inject.Provider 8 | 9 | /** 10 | * Factory responsible for creating all [ViewModel]s. 11 | */ 12 | class ViewModelFactory @Inject constructor( 13 | private val creators: Map, @JvmSuppressWildcards Provider>, 14 | application: Application 15 | ) : ViewModelProvider.AndroidViewModelFactory(application) { 16 | 17 | override fun create(viewModelClass: Class): ViewModelType { 18 | var creator: Provider? = creators[viewModelClass] 19 | if (creator == null) { 20 | for ((key, value) in creators) { 21 | if (viewModelClass.isAssignableFrom(key)) { 22 | creator = value 23 | break 24 | } 25 | } 26 | } 27 | 28 | if (creator == null) throw IllegalArgumentException("Unknown ViewModel class $viewModelClass") 29 | @Suppress("UNCHECKED_CAST") 30 | return creator.get() as ViewModelType 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/di/ViewModelKey.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.di 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.MapKey 5 | import kotlin.reflect.KClass 6 | 7 | @Target(AnnotationTarget.FUNCTION) 8 | @Retention(AnnotationRetention.RUNTIME) 9 | @MapKey 10 | internal annotation class ViewModelKey(val value: KClass) -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/di/component/ApplicationComponent.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.di.component 2 | 3 | import android.app.Application 4 | import com.nerdery.rtaza.jobtracker.di.module.* 5 | import com.nerdery.rtaza.jobtracker.ui.JobTrackerApplication 6 | import dagger.BindsInstance 7 | import dagger.Component 8 | import dagger.android.AndroidInjectionModule 9 | import javax.inject.Singleton 10 | 11 | @Singleton 12 | @Component( 13 | modules = [ 14 | AndroidInjectionModule::class, 15 | ApplicationModule::class, 16 | PersistenceModule::class, 17 | HttpModule::class, 18 | ViewModelModule::class, 19 | AdapterModule::class 20 | ] 21 | ) 22 | interface ApplicationComponent { 23 | 24 | @Component.Builder 25 | interface Builder { 26 | 27 | @BindsInstance 28 | fun application(application: Application): Builder 29 | 30 | fun build(): ApplicationComponent 31 | } 32 | 33 | fun inject(application: JobTrackerApplication) 34 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/di/module/AdapterModule.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.di.module 2 | 3 | import com.nerdery.rtaza.jobtracker.ui.jobs.JobsListAdapter 4 | import dagger.Module 5 | import dagger.Provides 6 | 7 | @Module 8 | class AdapterModule { 9 | 10 | @Provides 11 | fun provideJobsListAdapter(): JobsListAdapter { 12 | return JobsListAdapter() 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/di/module/ApplicationModule.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.di.module 2 | 3 | import com.nerdery.rtaza.jobtracker.di.ActivityScope 4 | import com.nerdery.rtaza.jobtracker.ui.jobs.JobsActivity 5 | import dagger.Module 6 | import dagger.android.ContributesAndroidInjector 7 | 8 | @Module 9 | abstract class ApplicationModule { 10 | 11 | @ActivityScope 12 | @ContributesAndroidInjector 13 | abstract fun contributeMainActivityInjector(): JobsActivity 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/di/module/HttpModule.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.di.module 2 | 3 | import com.nerdery.rtaza.jobtracker.BuildConfig 4 | import com.nerdery.rtaza.jobtracker.data.remote.HttpConstants 5 | import com.nerdery.rtaza.jobtracker.data.remote.adapter.RxErrorHandlingCallAdapterFactory 6 | import com.nerdery.rtaza.jobtracker.data.remote.api.JobWebApi 7 | import com.squareup.moshi.Moshi 8 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory 9 | import dagger.Module 10 | import dagger.Provides 11 | import io.reactivex.schedulers.Schedulers 12 | import okhttp3.OkHttpClient 13 | import okhttp3.logging.HttpLoggingInterceptor 14 | import retrofit2.Retrofit 15 | import retrofit2.converter.moshi.MoshiConverterFactory 16 | import timber.log.Timber 17 | 18 | @Module 19 | class HttpModule { 20 | 21 | @Provides 22 | fun provideMoshi(): Moshi { 23 | return Moshi.Builder() 24 | .add(KotlinJsonAdapterFactory()) 25 | .build() 26 | } 27 | 28 | @Provides 29 | fun provideOkHttpClient(loggingInterceptor: HttpLoggingInterceptor): OkHttpClient { 30 | return OkHttpClient.Builder() 31 | .addInterceptor(loggingInterceptor) 32 | .connectTimeout(HttpConstants.TIMEOUT_IO, HttpConstants.TIMEOUT_TIME_UNIT) 33 | .readTimeout(HttpConstants.TIMEOUT_IO, HttpConstants.TIMEOUT_TIME_UNIT) 34 | .writeTimeout(HttpConstants.TIMEOUT_IO, HttpConstants.TIMEOUT_TIME_UNIT) 35 | .build() 36 | } 37 | 38 | @Provides 39 | fun provideRetrofit(httpClient: OkHttpClient, moshi: Moshi): Retrofit { 40 | return Retrofit.Builder() 41 | .baseUrl(BuildConfig.API_BASE_URL) 42 | .addCallAdapterFactory(RxErrorHandlingCallAdapterFactory(Schedulers.io())) 43 | .addConverterFactory(MoshiConverterFactory.create(moshi)) 44 | .client(httpClient) 45 | .build() 46 | } 47 | 48 | @Provides 49 | fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor { 50 | val loggingInterceptor = HttpLoggingInterceptor(HttpLoggingInterceptor.Logger { message -> 51 | Timber.tag("OkHttp") 52 | .d(message) 53 | }) 54 | loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY 55 | return loggingInterceptor 56 | } 57 | 58 | @Provides 59 | fun provideJobWebApi(retrofit: Retrofit): JobWebApi { 60 | return retrofit.create(JobWebApi::class.java) 61 | } 62 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/di/module/PersistenceModule.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.di.module 2 | 3 | import android.app.Application 4 | import androidx.room.Room 5 | import com.nerdery.rtaza.jobtracker.data.local.ApplicationDatabase 6 | import com.nerdery.rtaza.jobtracker.data.local.dao.JobLocalDataSource 7 | import com.nerdery.rtaza.jobtracker.data.local.dao.WorkerLocalDataSource 8 | import dagger.Module 9 | import dagger.Provides 10 | 11 | @Module 12 | class PersistenceModule { 13 | 14 | @Provides 15 | fun provideRoomDatabase(application: Application): ApplicationDatabase { 16 | return Room.databaseBuilder(application, ApplicationDatabase::class.java, "job-tracker-database") 17 | .fallbackToDestructiveMigration() 18 | .build() 19 | } 20 | 21 | @Provides 22 | fun provideJobLocalDataSource(database: ApplicationDatabase): JobLocalDataSource { 23 | return database.jobDao() 24 | } 25 | 26 | @Provides 27 | fun provideWorkerLocalDataSource(database: ApplicationDatabase): WorkerLocalDataSource { 28 | return database.workerDao() 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/di/module/ViewModelModule.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.di.module 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import com.nerdery.rtaza.jobtracker.di.ViewModelFactory 6 | import com.nerdery.rtaza.jobtracker.di.ViewModelKey 7 | import com.nerdery.rtaza.jobtracker.ui.jobs.JobsViewModel 8 | import dagger.Binds 9 | import dagger.Module 10 | import dagger.multibindings.IntoMap 11 | 12 | @Module 13 | abstract class ViewModelModule { 14 | 15 | @Binds 16 | abstract fun bindViewModelFactory(viewModelFactory: ViewModelFactory): ViewModelProvider.Factory 17 | 18 | @Binds 19 | @IntoMap 20 | @ViewModelKey(JobsViewModel::class) 21 | abstract fun bindJobsViewModel(viewModel: JobsViewModel): ViewModel 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/log/LogUtil.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.log 2 | 3 | class LogUtil { 4 | 5 | companion object { 6 | 7 | /** 8 | * Returns the simple class name of [objectInstance]. 9 | */ 10 | fun getLogTag(objectInstance: Any): String { 11 | val clazz = objectInstance::class 12 | return String.format("%s{%s}", clazz.simpleName, Integer.toHexString(clazz.hashCode())) 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/JobTrackerApplication.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui 2 | 3 | import android.app.Activity 4 | import android.app.Application 5 | import com.nerdery.rtaza.jobtracker.BuildConfig 6 | import com.nerdery.rtaza.jobtracker.di.component.DaggerApplicationComponent 7 | import dagger.android.AndroidInjector 8 | import dagger.android.DispatchingAndroidInjector 9 | import dagger.android.HasActivityInjector 10 | import io.reactivex.exceptions.CompositeException 11 | import io.reactivex.exceptions.OnErrorNotImplementedException 12 | import io.reactivex.exceptions.UndeliverableException 13 | import io.reactivex.plugins.RxJavaPlugins 14 | import timber.log.Timber 15 | import javax.inject.Inject 16 | 17 | class JobTrackerApplication : Application(), HasActivityInjector { 18 | @Inject lateinit var activityInjector: DispatchingAndroidInjector 19 | 20 | override fun onCreate() { 21 | DaggerApplicationComponent.builder() 22 | .application(this) 23 | .build() 24 | .inject(this) 25 | super.onCreate() 26 | 27 | if (BuildConfig.DEBUG) { 28 | Timber.plant(Timber.DebugTree()) 29 | } 30 | 31 | setUncaughtRxJavaErrorHandler() 32 | } 33 | 34 | /** 35 | * Handles and logs all uncaught exceptions thrown through any RxJava stream in the application that does not 36 | * override onError. This prevents the app from crashing if a stream that doesn't override onError receives an 37 | * exception. 38 | */ 39 | private fun setUncaughtRxJavaErrorHandler() { 40 | RxJavaPlugins.setErrorHandler { throwable -> 41 | val cause: Throwable? = 42 | if (throwable is OnErrorNotImplementedException || throwable is UndeliverableException) { 43 | throwable.cause 44 | } else { 45 | throwable 46 | } 47 | 48 | if (throwable is CompositeException) { 49 | for (exception in throwable.exceptions) { 50 | Timber.e(exception) 51 | } 52 | } else { 53 | Timber.e(cause) 54 | } 55 | } 56 | } 57 | 58 | override fun activityInjector(): AndroidInjector { 59 | return activityInjector 60 | } 61 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/core/BaseActivity.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.core 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.appcompat.widget.Toolbar 6 | import com.nerdery.rtaza.jobtracker.R 7 | import com.nerdery.rtaza.jobtracker.log.LogUtil 8 | import timber.log.Timber 9 | 10 | abstract class BaseActivity : AppCompatActivity() { 11 | 12 | override fun onCreate(savedInstanceState: Bundle?) { 13 | Timber.tag(LogUtil.getLogTag(this)) 14 | Timber.v("onCreate") 15 | super.onCreate(savedInstanceState) 16 | } 17 | 18 | override fun setContentView(layoutResID: Int) { 19 | super.setContentView(layoutResID) 20 | val toolbar: Toolbar? = findViewById(R.id.actionBar) 21 | if (toolbar != null) { 22 | initializeAppBar(toolbar) 23 | } 24 | } 25 | 26 | private fun initializeAppBar(toolbar: Toolbar) { 27 | setSupportActionBar(toolbar) 28 | 29 | if (parentActivityIntent != null) { 30 | supportActionBar?.setDisplayHomeAsUpEnabled(true) 31 | } 32 | } 33 | 34 | override fun onStart() { 35 | Timber.tag(LogUtil.getLogTag(this)) 36 | Timber.v("onStart") 37 | super.onStart() 38 | } 39 | 40 | override fun onResume() { 41 | Timber.tag(LogUtil.getLogTag(this)) 42 | Timber.v("onResume") 43 | super.onResume() 44 | } 45 | 46 | override fun onPause() { 47 | Timber.tag(LogUtil.getLogTag(this)) 48 | Timber.v("onPause") 49 | super.onPause() 50 | } 51 | 52 | override fun onSaveInstanceState(outState: Bundle) { 53 | Timber.tag(LogUtil.getLogTag(this)) 54 | Timber.v("onSaveInstanceState") 55 | super.onSaveInstanceState(outState) 56 | } 57 | 58 | override fun onStop() { 59 | Timber.tag(LogUtil.getLogTag(this)) 60 | Timber.v("onStop") 61 | super.onStop() 62 | } 63 | 64 | override fun onDestroy() { 65 | Timber.tag(LogUtil.getLogTag(this)) 66 | Timber.v("onDestroy") 67 | super.onDestroy() 68 | } 69 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/core/BaseViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.core 2 | 3 | import android.app.Application 4 | import androidx.lifecycle.AndroidViewModel 5 | import io.reactivex.disposables.CompositeDisposable 6 | 7 | abstract class BaseViewModel(application: Application) : AndroidViewModel(application) { 8 | val compositeDisposable = CompositeDisposable() 9 | 10 | override fun onCleared() { 11 | compositeDisposable.clear() 12 | super.onCleared() 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/core/SingleLiveEvent.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.core 2 | 3 | import androidx.annotation.MainThread 4 | import androidx.lifecycle.LifecycleOwner 5 | import androidx.lifecycle.MutableLiveData 6 | import androidx.lifecycle.Observer 7 | import timber.log.Timber 8 | import java.util.concurrent.atomic.AtomicBoolean 9 | 10 | /** 11 | * A lifecycle-aware observable that sends only new updates after subscription. Used for events like navigation and 12 | * Toast messages. 13 | * 14 | * This avoids a common problem with LiveData: on configuration change (like rotation of the device), an update can 15 | * be emitted if the observer is active. This LiveData only calls the observable if there's an explicit call to 16 | * setValue() or call(). 17 | * 18 | * Note that only one observer is going to be notified of changes. 19 | */ 20 | class SingleLiveEvent : MutableLiveData() { 21 | private val pending = AtomicBoolean(false) 22 | 23 | @MainThread 24 | override fun observe(owner: LifecycleOwner, observer: Observer) { 25 | if (hasActiveObservers()) { 26 | Timber.w("Multiple observers registered but only one will be notified of changes") 27 | } 28 | 29 | // Observe the internal MutableLiveData 30 | super.observe(owner, Observer { data -> 31 | if (pending.compareAndSet(true, false)) { 32 | observer.onChanged(data) 33 | } 34 | }) 35 | } 36 | 37 | @MainThread 38 | override fun setValue(value: DataType?) { 39 | pending.set(true) 40 | super.setValue(value) 41 | } 42 | 43 | /** 44 | * Used for cases where T is Void, to make calls cleaner. 45 | */ 46 | @MainThread 47 | fun call() { 48 | value = null 49 | } 50 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/jobs/JobViewHolder.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.jobs 2 | 3 | import android.view.View 4 | import androidx.annotation.ColorInt 5 | import androidx.core.content.ContextCompat 6 | import androidx.recyclerview.widget.RecyclerView 7 | import com.nerdery.rtaza.jobtracker.ui.util.* 8 | import kotlinx.android.synthetic.main.item_job.view.* 9 | 10 | class JobViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 11 | private lateinit var presentationModel: JobsViewModel.Presentation.Model 12 | 13 | fun bind(presentationModel: JobsViewModel.Presentation.Model) { 14 | this.presentationModel = presentationModel 15 | itemView.apply { 16 | taskImageView.setImageResource(presentationModel.jobTaskIconId) 17 | addressLine1TextView.text = presentationModel.addressLine1Text 18 | addressLine2TextView.text = presentationModel.addressLine2Text 19 | taskTypeTextView.text = context.getString(presentationModel.jobTaskTextId) 20 | statusTextView.text = context.getString(presentationModel.jobStatusTextId) 21 | workerTextView.text = presentationModel.workerNameText 22 | updateEta() 23 | } 24 | itemView.setOnClickListener { 25 | // TODO: Start detail Activity 26 | } 27 | } 28 | 29 | fun updateEta() { 30 | itemView.apply { 31 | val dueInTime = TimeUtil.calculateDueInTime(presentationModel.eta) 32 | etaTextView.text = TextFormatter.formatDueInTime(itemView.context, dueInTime) 33 | @ColorInt val etaStatusColor = ContextCompat.getColor( 34 | context, JobIconUtil.getEtaStatusColorId(presentationModel.jobStatus, presentationModel.eta) 35 | ) 36 | taskImageView.setBackgroundTint(etaStatusColor) 37 | 38 | if (dueInTime < 0) { 39 | etaTextView.setTextColor(etaStatusColor) 40 | } else { 41 | etaTextView.setTextColor(context.resolveThemeAttribute(android.R.attr.textColorPrimary)) 42 | } 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/jobs/JobsActivity.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.jobs 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.lifecycle.ViewModelProvider 6 | import androidx.lifecycle.ViewModelProviders 7 | import androidx.recyclerview.widget.LinearLayoutManager 8 | import com.nerdery.rtaza.jobtracker.R 9 | import com.nerdery.rtaza.jobtracker.ui.core.BaseActivity 10 | import com.nerdery.rtaza.jobtracker.ui.util.observeNonNull 11 | import com.nerdery.rtaza.jobtracker.ui.view.OffsetItemDecoration 12 | import dagger.android.AndroidInjection 13 | import kotlinx.android.synthetic.main.activity_jobs.* 14 | import javax.inject.Inject 15 | 16 | class JobsActivity : BaseActivity() { 17 | private lateinit var viewModel: JobsViewModel 18 | @Inject lateinit var viewModelFactory: ViewModelProvider.Factory 19 | @Inject lateinit var listAdapter: JobsListAdapter 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | AndroidInjection.inject(this) 23 | super.onCreate(savedInstanceState) 24 | setContentView(R.layout.activity_jobs) 25 | 26 | recyclerView.apply { 27 | addItemDecoration( 28 | OffsetItemDecoration( 29 | topOffset = resources.getDimensionPixelSize(R.dimen.spacing_small), 30 | bottomOffset = resources.getDimensionPixelSize(R.dimen.spacing_small), 31 | leftOffset = resources.getDimensionPixelSize(R.dimen.spacing_medium), 32 | rightOffset = resources.getDimensionPixelSize(R.dimen.spacing_medium) 33 | ) 34 | ) 35 | lifecycle.addObserver(listAdapter) 36 | adapter = listAdapter 37 | layoutManager = LinearLayoutManager(context) 38 | } 39 | 40 | viewModel = ViewModelProviders.of(this, viewModelFactory).get(JobsViewModel::class.java) 41 | 42 | viewModel.loading.observeNonNull(this) { loading -> 43 | progressBar.visibility = if (loading && listAdapter.isEmpty()) { 44 | View.VISIBLE 45 | } else { 46 | View.GONE 47 | } 48 | } 49 | 50 | viewModel.getPresentation().observeNonNull(this) { presentation -> 51 | toggleEmptyStateVisibility(false) 52 | listAdapter.submitList(presentation.models) 53 | } 54 | 55 | viewModel.getNoJobsFound().observeNonNull(this) { 56 | emptyStateImageView.setImageResource(R.drawable.icon_no_active_jobs) 57 | emptyStateTitleTextView.setText(R.string.empty_state_title_no_jobs) 58 | emptyStateDescriptionTextView.setText(R.string.empty_state_description_no_jobs) 59 | toggleEmptyStateVisibility(true) 60 | } 61 | 62 | viewModel.error.observeNonNull(this) { error -> 63 | if (listAdapter.isEmpty()) { 64 | emptyStateImageView.setImageResource(error.iconResourceId) 65 | emptyStateTitleTextView.setText(error.titleResourceId) 66 | emptyStateDescriptionTextView.setText(error.descriptionResourceId) 67 | toggleEmptyStateVisibility(true) 68 | } 69 | // Don't show an error if there're jobs in the list loaded from local persistence since the request was not 70 | // explicitly requested by the user, but consider adding some sort of indication that the jobs being shown 71 | // may not be the freshest compared to what's available on the backend. 72 | } 73 | 74 | viewModel.bind() 75 | } 76 | 77 | private fun toggleEmptyStateVisibility(show: Boolean) { 78 | if (show) { 79 | recyclerView.visibility = View.INVISIBLE 80 | emptyStateGroup.visibility = View.VISIBLE 81 | } else { 82 | emptyStateGroup.visibility = View.GONE 83 | recyclerView.visibility = View.VISIBLE 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/jobs/JobsListAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.jobs 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.lifecycle.Lifecycle 6 | import androidx.lifecycle.LifecycleObserver 7 | import androidx.lifecycle.OnLifecycleEvent 8 | import androidx.recyclerview.widget.DiffUtil 9 | import androidx.recyclerview.widget.ListAdapter 10 | import com.nerdery.rtaza.jobtracker.R 11 | import com.nerdery.rtaza.jobtracker.ui.util.isNullOrEmpty 12 | import io.reactivex.Observable 13 | import io.reactivex.android.schedulers.AndroidSchedulers 14 | import io.reactivex.disposables.Disposable 15 | import java.util.concurrent.TimeUnit 16 | 17 | class JobsListAdapter : ListAdapter(diffUtilItemCallback), 18 | LifecycleObserver { 19 | private var dataSet: List? = null 20 | private val etaUpdateInterval = Pair(1, TimeUnit.MINUTES) 21 | private var etaUpdateObservable: Observable 22 | private var etaUpdateDisposable: Disposable? = null 23 | 24 | init { 25 | setHasStableIds(true) 26 | etaUpdateObservable = Observable.interval(etaUpdateInterval.first, etaUpdateInterval.second) 27 | .observeOn(AndroidSchedulers.mainThread()) 28 | } 29 | 30 | override fun submitList(list: List?) { 31 | // Reset the ETA update observable anytime a new list is submitted 32 | etaUpdateDisposable?.dispose() 33 | dataSet = list 34 | super.submitList(list) 35 | startEtaUpdateInterval() 36 | } 37 | 38 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): JobViewHolder { 39 | val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item_job, parent, false) 40 | return JobViewHolder(itemView) 41 | } 42 | 43 | override fun onBindViewHolder(holder: JobViewHolder, position: Int) { 44 | holder.bind(getItem(position)) 45 | } 46 | 47 | override fun onBindViewHolder(holder: JobViewHolder, position: Int, payloads: MutableList) { 48 | if (payloads.isEmpty()) { 49 | onBindViewHolder(holder, position) 50 | } else { 51 | for (payload in payloads) { 52 | when (payload) { 53 | is EtaUpdatePayload -> holder.updateEta() 54 | } 55 | } 56 | } 57 | } 58 | 59 | override fun getItemId(position: Int): Long { 60 | return getItem(position).jobId 61 | } 62 | 63 | fun isEmpty() = dataSet.isNullOrEmpty() 64 | 65 | @OnLifecycleEvent(Lifecycle.Event.ON_START) 66 | fun startEtaUpdateInterval() { 67 | if (!isEmpty()) { 68 | etaUpdateDisposable = etaUpdateObservable.subscribe { 69 | updateEtas() 70 | } 71 | } 72 | } 73 | 74 | @OnLifecycleEvent(Lifecycle.Event.ON_STOP) 75 | fun clearDisposables() { 76 | etaUpdateDisposable?.dispose() 77 | } 78 | 79 | private fun updateEtas() { 80 | notifyItemRangeChanged(0, itemCount, EtaUpdatePayload()) 81 | } 82 | } 83 | 84 | private val diffUtilItemCallback = object : DiffUtil.ItemCallback() { 85 | override fun areItemsTheSame( 86 | oldModel: JobsViewModel.Presentation.Model, 87 | newModel: JobsViewModel.Presentation.Model 88 | ): Boolean { 89 | return oldModel.jobId == newModel.jobId 90 | } 91 | 92 | override fun areContentsTheSame( 93 | oldModel: JobsViewModel.Presentation.Model, 94 | newModel: JobsViewModel.Presentation.Model 95 | ): Boolean { 96 | return oldModel == newModel 97 | } 98 | 99 | override fun getChangePayload( 100 | oldItem: JobsViewModel.Presentation.Model, 101 | newItem: JobsViewModel.Presentation.Model 102 | ): Any { 103 | // Prevent recyclable view holders from being recreated and trigger a full rebind 104 | return newItem 105 | } 106 | } 107 | 108 | private class EtaUpdatePayload -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/jobs/JobsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.jobs 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import androidx.annotation.DrawableRes 6 | import androidx.annotation.StringRes 7 | import androidx.lifecycle.LiveData 8 | import androidx.lifecycle.MutableLiveData 9 | import com.nerdery.rtaza.jobtracker.R 10 | import com.nerdery.rtaza.jobtracker.data.core.Error 11 | import com.nerdery.rtaza.jobtracker.data.core.Resource 12 | import com.nerdery.rtaza.jobtracker.data.local.model.JobStatus 13 | import com.nerdery.rtaza.jobtracker.data.local.model.JobWithRelations 14 | import com.nerdery.rtaza.jobtracker.data.repository.JobRepository 15 | import com.nerdery.rtaza.jobtracker.ui.core.BaseViewModel 16 | import com.nerdery.rtaza.jobtracker.ui.core.SingleLiveEvent 17 | import com.nerdery.rtaza.jobtracker.ui.util.JobIconUtil 18 | import com.nerdery.rtaza.jobtracker.ui.util.TextFormatter 19 | import io.reactivex.rxkotlin.addTo 20 | import io.reactivex.schedulers.Schedulers 21 | import javax.inject.Inject 22 | 23 | class JobsViewModel @Inject constructor( 24 | application: Application, 25 | private val jobRepository: JobRepository 26 | ) : BaseViewModel(application) { 27 | val loading: SingleLiveEvent = SingleLiveEvent() 28 | val error: SingleLiveEvent = SingleLiveEvent() 29 | private val presentation: MutableLiveData = MutableLiveData() 30 | private val noJobsFound: MutableLiveData = MutableLiveData() 31 | 32 | fun getPresentation(): LiveData = presentation 33 | 34 | fun getNoJobsFound(): LiveData = noJobsFound 35 | 36 | fun bind() { 37 | jobRepository.getJobs(true) 38 | .subscribeOn(Schedulers.io()) 39 | .subscribe { resource: Resource> -> 40 | when (resource) { 41 | is Resource.Loading -> { 42 | if (resource.data != null) { 43 | presentation.postValue(Presentation(getApplication(), resource.data)) 44 | } 45 | loading.postValue(true) 46 | } 47 | is Resource.ResourceFound -> { 48 | presentation.postValue(Presentation(getApplication(), resource.data!!)) 49 | loading.postValue(false) 50 | } 51 | is Resource.NoResourceFound -> { 52 | noJobsFound.postValue(Unit) 53 | loading.postValue(false) 54 | } 55 | is Resource.Error -> { 56 | error.postValue(resource.error) 57 | loading.postValue(false) 58 | } 59 | } 60 | }.addTo(compositeDisposable) 61 | } 62 | 63 | data class Presentation( 64 | private val context: Context, 65 | private val jobsWithRelations: List 66 | ) { 67 | val models: MutableList = ArrayList(jobsWithRelations.size) 68 | 69 | init { 70 | jobsWithRelations.forEach { jobWithRelations -> 71 | with(jobWithRelations.job) { 72 | models.add( 73 | Model( 74 | id, 75 | getStatus(), 76 | getStatus().label, 77 | task.label, 78 | JobIconUtil.getTaskIconId(task), 79 | location.street, 80 | context.getString(R.string.format_city_state, location.city, location.state), 81 | TextFormatter.formatFullName( 82 | context, 83 | jobWithRelations.worker?.firstName, 84 | jobWithRelations.worker?.lastName 85 | ), 86 | eta 87 | ) 88 | ) 89 | } 90 | } 91 | } 92 | 93 | data class Model( 94 | val jobId: Long, 95 | val jobStatus: JobStatus, 96 | @StringRes val jobStatusTextId: Int, 97 | @StringRes val jobTaskTextId: Int, 98 | @DrawableRes val jobTaskIconId: Int, 99 | val addressLine1Text: String, 100 | val addressLine2Text: String, 101 | val workerNameText: String, 102 | val eta: Long 103 | ) 104 | } 105 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/util/CollectionExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.util 2 | 3 | fun Collection?.isNullOrEmpty(): Boolean { 4 | return this == null || isEmpty() 5 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/util/ContextExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.util 2 | 3 | import android.content.Context 4 | import android.util.TypedValue 5 | import androidx.annotation.AttrRes 6 | 7 | /** 8 | * Retrieve the value of an attribute in the Theme. 9 | */ 10 | fun Context.resolveThemeAttribute(@AttrRes resourceId: Int): Int { 11 | val typedValue = TypedValue() 12 | theme.resolveAttribute(resourceId, typedValue, true) 13 | return typedValue.data 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/util/JobIconUtil.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.util 2 | 3 | import androidx.annotation.ColorRes 4 | import androidx.annotation.DrawableRes 5 | import com.nerdery.rtaza.jobtracker.R 6 | import com.nerdery.rtaza.jobtracker.data.local.model.JobStatus 7 | import com.nerdery.rtaza.jobtracker.data.local.model.JobTask 8 | 9 | const val JUDICIOUSLY_LATE_IN_MINUTES = 1 10 | const val EXCEEDINGLY_LATE_IN_MINUTES = 11 11 | 12 | class JobIconUtil { 13 | 14 | companion object { 15 | 16 | @DrawableRes 17 | fun getTaskIconId(task: JobTask): Int { 18 | return when (task) { 19 | JobTask.ELECTRICAL -> R.drawable.icon_electrical 20 | JobTask.HVAC -> R.drawable.icon_hvac 21 | JobTask.PLUMBING -> R.drawable.icon_plumbing 22 | JobTask.CARPENTRY -> R.drawable.icon_carpentry 23 | JobTask.PAINT -> R.drawable.icon_paint 24 | } 25 | } 26 | 27 | @ColorRes 28 | fun getEtaStatusColorId(status: JobStatus, promisedEta: Long?): Int { 29 | if (status === JobStatus.NEW || status === JobStatus.ON_SITE) { 30 | return R.color.job_eta_inapplicable 31 | } 32 | 33 | if (promisedEta == null) { 34 | return R.color.job_eta_unknown 35 | } 36 | 37 | val minutesLate = TimeUtil.calculateMinutesLate(promisedEta) 38 | return when { 39 | minutesLate < JUDICIOUSLY_LATE_IN_MINUTES -> R.color.job_eta_on_time 40 | minutesLate <= EXCEEDINGLY_LATE_IN_MINUTES -> R.color.job_eta_late 41 | else -> R.color.job_eta_exceedingly_late 42 | } 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/util/LiveDataExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.util 2 | 3 | import androidx.lifecycle.LifecycleOwner 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.Observer 6 | 7 | fun LiveData.observeNonNull(owner: LifecycleOwner, observer: (data: T) -> Unit) { 8 | this.observe(owner, Observer { data -> 9 | data?.let(observer) 10 | }) 11 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/util/StringExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.util 2 | 3 | fun String.Companion.join(delimiter: String?, vararg strings: String?): String? { 4 | val builder = StringBuilder() 5 | var string: String? 6 | 7 | for (index in 0 until strings.size) { 8 | string = strings[index] 9 | 10 | if (!string.isNullOrEmpty()) { 11 | if (builder.isNotEmpty()) { 12 | builder.append(delimiter) 13 | } 14 | builder.append(string) 15 | } 16 | } 17 | 18 | return if (builder.isEmpty()) null else builder.toString() 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/util/TextFormatter.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.util 2 | 3 | import android.content.Context 4 | import com.nerdery.rtaza.jobtracker.R 5 | 6 | class TextFormatter { 7 | 8 | companion object { 9 | 10 | fun formatFullName(context: Context, firstName: String?, lastName: String?): String { 11 | val joinedStrings = String.join(" ", firstName, lastName) 12 | 13 | return joinedStrings ?: context.getString(R.string.data_not_available) 14 | } 15 | 16 | fun formatDueInTime(context: Context, dueInTime: Int): String { 17 | return when { 18 | dueInTime > 0 -> context.resources.getQuantityString(R.plurals.minutes, dueInTime, dueInTime) 19 | dueInTime < 0 -> context.getString(R.string.eta_negative) 20 | else -> context.getString(R.string.eta_now) 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/util/TimeUtil.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.util 2 | 3 | import java.util.concurrent.TimeUnit 4 | 5 | class TimeUtil { 6 | 7 | companion object { 8 | 9 | fun getCurrentTimeMillis() = System.currentTimeMillis() 10 | 11 | /** 12 | * Calculates the difference between the [promisedEta] and the current time. 13 | * 14 | * @param promisedEta the promised ETA in milliseconds. 15 | * @return the difference between the [promisedEta] and the current time in minutes. 16 | */ 17 | fun calculateDueInTime(promisedEta: Long): Int { 18 | return (TimeUnit.MILLISECONDS.toMinutes(promisedEta) - 19 | TimeUnit.MILLISECONDS.toMinutes(getCurrentTimeMillis())).toInt() 20 | } 21 | 22 | /** 23 | * Calculates the difference between the the current time and the [promisedEta]. 24 | * 25 | * @param promisedEta the promised ETA in milliseconds. 26 | * @return the difference between the current time and the [promisedEta] in minutes. 27 | * Returns 0 if the difference is negative (not late). 28 | */ 29 | fun calculateMinutesLate(promisedEta: Long): Int { 30 | val diff = TimeUnit.MILLISECONDS.toMinutes(getCurrentTimeMillis()) - 31 | TimeUnit.MILLISECONDS.toMinutes(promisedEta) 32 | return if (diff > 0) diff.toInt() else 0 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/util/ViewExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.util 2 | 3 | import android.content.res.ColorStateList 4 | import android.view.View 5 | import androidx.annotation.ColorInt 6 | import androidx.core.view.ViewCompat 7 | 8 | fun View.setBackgroundTint(@ColorInt color: Int) = 9 | ViewCompat.setBackgroundTintList(this, ColorStateList.valueOf(color)) -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/util/Visibility.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.util 2 | 3 | import android.view.View 4 | import androidx.annotation.IntDef 5 | 6 | @IntDef(View.VISIBLE, View.INVISIBLE, View.GONE) 7 | @Retention(AnnotationRetention.SOURCE) 8 | annotation class Visibility -------------------------------------------------------------------------------- /app/src/main/java/com/nerdery/rtaza/jobtracker/ui/view/OffsetItemDecoration.kt: -------------------------------------------------------------------------------- 1 | package com.nerdery.rtaza.jobtracker.ui.view 2 | 3 | import android.graphics.Rect 4 | import android.view.View 5 | import androidx.recyclerview.widget.RecyclerView 6 | 7 | /** 8 | * [RecyclerView.ItemDecoration] responsible for adding top, bottom, left, and right offsets to each item view of a 9 | * [RecyclerView]. 10 | */ 11 | class OffsetItemDecoration( 12 | private val topOffset: Int = 0, 13 | private val bottomOffset: Int = 0, 14 | private val leftOffset: Int = 0, 15 | private val rightOffset: Int = 0 16 | ) : RecyclerView.ItemDecoration() { 17 | 18 | override fun getItemOffsets( 19 | offsetsRect: Rect, 20 | itemView: View, 21 | recyclerView: RecyclerView, 22 | state: RecyclerView.State 23 | ) { 24 | with(offsetsRect) { 25 | if (recyclerView.getChildAdapterPosition(itemView) == 0) { 26 | top = topOffset 27 | } 28 | bottom = bottomOffset 29 | left = leftOffset 30 | right = rightOffset 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/foreground_launch.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/background_launch.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_background_job_status.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_carpentry.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_electrical.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_error_client_io.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_error_server.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 13 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_error_uauthorized.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_error_unknown.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_hvac.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_no_active_jobs.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_paint.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_plumbing.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_jobs.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 14 | 15 | 25 | 26 | 34 | 35 | 40 | 41 | 52 | 53 | 67 | 68 | 82 | 83 | -------------------------------------------------------------------------------- /app/src/main/res/layout/item_job.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 16 | 17 | 28 | 29 | 42 | 43 | 54 | 55 | 62 | 63 | 75 | 76 | 87 | 88 | 100 | 101 | 112 | 113 | 119 | 120 | 130 | 131 | 141 | 142 | 152 | 153 | 163 | 164 | 165 | 166 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/icon_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/icon_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/icon_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryandt/JobTracker/926ca3679231eb4d72aba9d9b6350c2e504daacc/app/src/main/res/mipmap-hdpi/icon_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/icon_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryandt/JobTracker/926ca3679231eb4d72aba9d9b6350c2e504daacc/app/src/main/res/mipmap-hdpi/icon_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/icon_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryandt/JobTracker/926ca3679231eb4d72aba9d9b6350c2e504daacc/app/src/main/res/mipmap-mdpi/icon_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/icon_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryandt/JobTracker/926ca3679231eb4d72aba9d9b6350c2e504daacc/app/src/main/res/mipmap-mdpi/icon_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/icon_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryandt/JobTracker/926ca3679231eb4d72aba9d9b6350c2e504daacc/app/src/main/res/mipmap-xhdpi/icon_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/icon_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryandt/JobTracker/926ca3679231eb4d72aba9d9b6350c2e504daacc/app/src/main/res/mipmap-xhdpi/icon_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/icon_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryandt/JobTracker/926ca3679231eb4d72aba9d9b6350c2e504daacc/app/src/main/res/mipmap-xxhdpi/icon_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/icon_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryandt/JobTracker/926ca3679231eb4d72aba9d9b6350c2e504daacc/app/src/main/res/mipmap-xxhdpi/icon_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/icon_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryandt/JobTracker/926ca3679231eb4d72aba9d9b6350c2e504daacc/app/src/main/res/mipmap-xxxhdpi/icon_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/icon_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryandt/JobTracker/926ca3679231eb4d72aba9d9b6350c2e504daacc/app/src/main/res/mipmap-xxxhdpi/icon_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | #039be5 6 | #01579b 7 | #ff4081 8 | #ffffff 9 | #e5e5e5 10 | 11 | 12 | #de000000 13 | #8a000000 14 | #61000000 15 | #33000000 16 | 17 | 18 | #ffffff 19 | #b2ffffff 20 | #80ffffff 21 | #1fffffff 22 | 23 | 24 | #ffffff 25 | #0d000000 26 | #1f000000 27 | 28 | 29 | #039be5 30 | #43a047 31 | #fB8c00 32 | #e53935 33 | #000000 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10sp 6 | 12sp 7 | 14sp 8 | 14sp 9 | 16sp 10 | 20sp 11 | 24sp 12 | 34sp 13 | 48sp 14 | 15 | 16 | 4dp 17 | 8dp 18 | 12dp 19 | 16dp 20 | 24dp 21 | 32dp 22 | 48dp 23 | 24 | 25 | 24dp 26 | 48dp 27 | 60dp 28 | 105dp 29 | 130dp 30 | 31 | 32 | 18dp 33 | 24dp 34 | 36dp 35 | 48dp 36 | 37 | 38 | 4dp 39 | 2dp 40 | 1dp 41 | 42 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Job Tracker 5 | Request failed 6 | Check your network connection 7 | Request failed 8 | Something went wrong on our end 9 | Session expired 10 | Please log in again 11 | Request failed 12 | Something went wrong 13 | %1$s. %2$s. 14 | -- 15 | 16 | 17 | New 18 | Pending 19 | Declined 20 | Missed 21 | Accepted 22 | Rejected 23 | En Route 24 | On Site 25 | Completed 26 | Cancelled 27 | Dispatcher Cancelled 28 | 29 | 30 | Carpentry 31 | Paint 32 | Electrical 33 | Plumbing 34 | HVAC 35 | 36 | 37 | 38 | %d minute 39 | %d minutes 40 | 41 | Due now 42 | Overdue 43 | Task type 44 | Status 45 | Worker 46 | ETA 47 | %1$s, %2$s 48 | 49 | 50 | Active jobs 51 | No active jobs found 52 | We\'re looking for more work for you. In the meantime, take a break, free your mind, and relax your body. 53 | 54 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 15 | 16 | 24 | 25 | 31 | 32 | 40 | 41 | 50 | 51 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 17 | 20 | 21 | 24 | 25 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | kotlinVersion = "1.3.20" 4 | appCompatVersion = "1.1.0-alpha01" 5 | lifecycleVersion = "2.1.0-alpha02" 6 | materialVersion = "1.0.0" 7 | recyclerViewVersion = "1.1.0-alpha02" 8 | cardViewVersion = "1.0.0" 9 | constraintLayoutVersion = "1.1.3" 10 | roomVersion = "2.1.0-alpha04" 11 | retrofitVersion = "2.4.0" 12 | okHttpVersion = "3.10.0" 13 | rxAndroidVersion = "2.1.0" 14 | rxKotlinVersion = "2.3.0" 15 | moshiVersion = "1.6.0" 16 | daggerVersion = "2.16" 17 | timberVersion = "4.7.1" 18 | } 19 | 20 | repositories { 21 | google() 22 | jcenter() 23 | } 24 | 25 | dependencies { 26 | classpath "com.android.tools.build:gradle:3.3.0" 27 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" 28 | } 29 | } 30 | 31 | allprojects { 32 | repositories { 33 | google() 34 | jcenter() 35 | } 36 | } 37 | 38 | task clean(type: Delete) { 39 | delete rootProject.buildDir 40 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | android.enableJetifier=true 10 | android.useAndroidX=true 11 | org.gradle.jvmargs=-Xmx1536m 12 | # When configured, Gradle will run in incubating parallel mode. 13 | # This option should only be used with decoupled projects. More details, visit 14 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 15 | # org.gradle.parallel=true 16 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryandt/JobTracker/926ca3679231eb4d72aba9d9b6350c2e504daacc/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Jan 30 16:56:44 CST 2019 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /schemas/com.nerdery.rtaza.jobtracker.data.local.ApplicationDatabase/3.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 3, 5 | "identityHash": "7f615c3142a38943614a602fcb6fe12d", 6 | "entities": [ 7 | { 8 | "tableName": "Job", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `task` INTEGER NOT NULL, `status` INTEGER, `active` INTEGER NOT NULL, `workerId` INTEGER, `eta` INTEGER NOT NULL, `notes` TEXT, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `phoneNumber` TEXT NOT NULL, `street` TEXT NOT NULL, `city` TEXT NOT NULL, `zip` TEXT NOT NULL, `state` TEXT NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`workerId`) REFERENCES `Worker`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "task", 19 | "columnName": "task", 20 | "affinity": "INTEGER", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "status", 25 | "columnName": "status", 26 | "affinity": "INTEGER", 27 | "notNull": false 28 | }, 29 | { 30 | "fieldPath": "active", 31 | "columnName": "active", 32 | "affinity": "INTEGER", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "workerId", 37 | "columnName": "workerId", 38 | "affinity": "INTEGER", 39 | "notNull": false 40 | }, 41 | { 42 | "fieldPath": "eta", 43 | "columnName": "eta", 44 | "affinity": "INTEGER", 45 | "notNull": true 46 | }, 47 | { 48 | "fieldPath": "notes", 49 | "columnName": "notes", 50 | "affinity": "TEXT", 51 | "notNull": false 52 | }, 53 | { 54 | "fieldPath": "customer.firstName", 55 | "columnName": "firstName", 56 | "affinity": "TEXT", 57 | "notNull": true 58 | }, 59 | { 60 | "fieldPath": "customer.lastName", 61 | "columnName": "lastName", 62 | "affinity": "TEXT", 63 | "notNull": true 64 | }, 65 | { 66 | "fieldPath": "customer.phoneNumber", 67 | "columnName": "phoneNumber", 68 | "affinity": "TEXT", 69 | "notNull": true 70 | }, 71 | { 72 | "fieldPath": "location.street", 73 | "columnName": "street", 74 | "affinity": "TEXT", 75 | "notNull": true 76 | }, 77 | { 78 | "fieldPath": "location.city", 79 | "columnName": "city", 80 | "affinity": "TEXT", 81 | "notNull": true 82 | }, 83 | { 84 | "fieldPath": "location.zip", 85 | "columnName": "zip", 86 | "affinity": "TEXT", 87 | "notNull": true 88 | }, 89 | { 90 | "fieldPath": "location.state", 91 | "columnName": "state", 92 | "affinity": "TEXT", 93 | "notNull": true 94 | }, 95 | { 96 | "fieldPath": "location.latitude", 97 | "columnName": "latitude", 98 | "affinity": "REAL", 99 | "notNull": true 100 | }, 101 | { 102 | "fieldPath": "location.longitude", 103 | "columnName": "longitude", 104 | "affinity": "REAL", 105 | "notNull": true 106 | } 107 | ], 108 | "primaryKey": { 109 | "columnNames": [ 110 | "id" 111 | ], 112 | "autoGenerate": false 113 | }, 114 | "indices": [ 115 | { 116 | "name": "index_Job_workerId", 117 | "unique": false, 118 | "columnNames": [ 119 | "workerId" 120 | ], 121 | "createSql": "CREATE INDEX `index_Job_workerId` ON `${TABLE_NAME}` (`workerId`)" 122 | } 123 | ], 124 | "foreignKeys": [ 125 | { 126 | "table": "Worker", 127 | "onDelete": "NO ACTION", 128 | "onUpdate": "NO ACTION", 129 | "columns": [ 130 | "workerId" 131 | ], 132 | "referencedColumns": [ 133 | "id" 134 | ] 135 | } 136 | ] 137 | }, 138 | { 139 | "tableName": "Worker", 140 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `online` INTEGER NOT NULL, PRIMARY KEY(`id`))", 141 | "fields": [ 142 | { 143 | "fieldPath": "id", 144 | "columnName": "id", 145 | "affinity": "INTEGER", 146 | "notNull": true 147 | }, 148 | { 149 | "fieldPath": "firstName", 150 | "columnName": "firstName", 151 | "affinity": "TEXT", 152 | "notNull": true 153 | }, 154 | { 155 | "fieldPath": "lastName", 156 | "columnName": "lastName", 157 | "affinity": "TEXT", 158 | "notNull": true 159 | }, 160 | { 161 | "fieldPath": "online", 162 | "columnName": "online", 163 | "affinity": "INTEGER", 164 | "notNull": true 165 | } 166 | ], 167 | "primaryKey": { 168 | "columnNames": [ 169 | "id" 170 | ], 171 | "autoGenerate": false 172 | }, 173 | "indices": [], 174 | "foreignKeys": [] 175 | } 176 | ], 177 | "setupQueries": [ 178 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 179 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"7f615c3142a38943614a602fcb6fe12d\")" 180 | ] 181 | } 182 | } -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | --------------------------------------------------------------------------------