diff --git a/.circleci/config.yml b/.circleci/config.yml
index 4175da6..342196f 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,26 +1,130 @@
# Use the latest 2.1 version of CircleCI pipeline process engine.
-# See: https://circleci.com/docs/configuration-reference
+# See: https://circleci.com/docs/2.0/configuration-reference
+# For a detailed guide to building and testing on Android, read the docs:
+# https://circleci.com/docs/2.0/language-android/ for more details.
version: 2.1
-# Define a job to be invoked later in a workflow.
-# See: https://circleci.com/docs/configuration-reference/#jobs
-jobs:
- say-hello:
- # Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub.
- # See: https://circleci.com/docs/configuration-reference/#executor-job
- docker:
- - image: cimg/base:stable
- # Add steps to the job
- # See: https://circleci.com/docs/configuration-reference/#steps
+# Orbs are reusable packages of CircleCI configuration that you may share across projects, enabling you to create encapsulated, parameterized commands, jobs, and executors that can be used across multiple projects.
+# See: https://circleci.com/docs/2.0/orb-intro/
+orbs:
+ android: circleci/android@2.3.0
+
+commands:
+ setup_repo:
+ description: checkout repo and android dependencies
steps:
- checkout
- run:
- name: "Say hello"
- command: "echo Hello, World!"
-
-# Orchestrate jobs using workflows
-# See: https://circleci.com/docs/configuration-reference/#workflows
+ name: Give gradle permissions
+ command: |
+ sudo chmod +x ./gradlew
+ - android/restore-gradle-cache
+ run_tests:
+ description: run tests for flavour specified
+ steps:
+ # The next step will run the unit tests
+ - run:
+ name: Run local unit tests
+ command: |
+ ./gradlew testDebugUnitTest
+ - android/save-gradle-cache
+ - store_artifacts:
+ path: app/build/reports
+ destination: reports
+ - store_test_results:
+ path: app/build/test-results
+ run_ui_tests:
+ description: run instrumentation and espresso tests
+ steps:
+ - android/start-emulator-and-run-tests:
+ post-emulator-launch-assemble-command: ./gradlew assembleAndroidTest
+ test-command: ./gradlew connectedDebugAndroidTest --continue
+ system-image: system-images;android-26;google_apis;x86
+ # store screenshots for failed ui tests
+ - when:
+ condition: on_fail
+ steps:
+ - store_artifacts:
+ path: app/build/outputs/connected_android_test_additional_output/debugAndroidTest/connected
+ destination: connected_android_test
+ # store test reports
+ - store_artifacts:
+ path: app/build/reports/androidTests/connected
+ destination: reports
+ - store_test_results:
+ path: app/build/outputs/androidTest-results/connected
+ deploy_to_play_store:
+ description: deploy to playstore
+ steps:
+ # The next step will run the unit tests
+ - android/decode-keystore:
+ keystore-location: "./app/keystore.jks"
+ - run:
+ name: Setup playstore key
+ command: |
+ echo "$GOOGLE_PLAY_KEY" > "google-play-key.json"
+ - run:
+ name: Run fastlane command to deploy to playstore
+ command: |
+ pwd
+ bundle exec fastlane deploy
+ - store_test_results:
+ path: fastlane/report.xml
+# Define a job to be invoked later in a workflow.
+# See: https://circleci.com/docs/2.0/configuration-reference/#jobs
+jobs:
+ # Below is the definition of your job to build and test your app, you can rename and customize it as you want.
+ build-and-test:
+ # These next lines define the Android machine image executor.
+ # See: https://circleci.com/docs/2.0/executor-types/
+ executor:
+ name: android/android-machine
+ tag: 2023.05.1
+ # Add steps to the job
+ # See: https://circleci.com/docs/2.0/configuration-reference/#steps
+ steps:
+ - setup_repo
+ - run_tests
+ run_instrumentation_test:
+ # These next lines define the Android machine image executor.
+ # See: https://circleci.com/docs/2.0/executor-types/
+ executor:
+ name: android/android-machine
+ tag: 2023.05.1
+ # Add steps to the job
+ # See: https://circleci.com/docs/2.0/configuration-reference/#steps
+ steps:
+ - setup_repo
+ - run_ui_tests
+ deploy-to-playstore:
+ docker:
+ - image: cimg/android:2023.07-browsers
+ auth:
+ username: ${DOCKER_USERNAME}
+ password: ${DOCKER_PASSWORD}
+ steps:
+ - setup_repo
+ - deploy_to_play_store
+# Invoke jobs via workflows
+# See: https://circleci.com/docs/2.0/configuration-reference/#workflows
workflows:
- say-hello-workflow:
+ version: 2
+ build-release:
jobs:
- - say-hello
+ - build-and-test:
+ context: appttude
+ - run_instrumentation_test:
+ context: appttude
+ filters:
+ branches:
+ only:
+ - master
+ - release
+ - deploy-to-playstore:
+ context: appttude
+ filters:
+ branches:
+ only:
+ - release
+ requires:
+ - build-and-test
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 87e70e7..ae7f176 100644
--- a/.gitignore
+++ b/.gitignore
@@ -88,7 +88,7 @@ gen-external-apklibs
.idea/uiDesigner.xml
.idea/assetWizardSettings.xml
.idea/gradle.xml
-.idea/jarRepositorie
+.idea/jarRepositories.xml
# Gem/fastlane
Gemfile.lock
diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml
deleted file mode 100644
index 263c04c..0000000
--- a/.idea/androidTestResultsUserPreferences.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/assetWizardSettings.xml b/.idea/assetWizardSettings.xml
deleted file mode 100644
index 0068d43..0000000
--- a/.idea/assetWizardSettings.xml
+++ /dev/null
@@ -1,127 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/caches/build_file_checksums.ser b/.idea/caches/build_file_checksums.ser
deleted file mode 100644
index 3f6cd0f..0000000
Binary files a/.idea/caches/build_file_checksums.ser and /dev/null differ
diff --git a/.idea/caches/gradle_models.ser b/.idea/caches/gradle_models.ser
deleted file mode 100644
index 5971202..0000000
Binary files a/.idea/caches/gradle_models.ser and /dev/null differ
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
deleted file mode 100644
index b589d56..0000000
--- a/.idea/compiler.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index 1431050..0000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
deleted file mode 100644
index eb2873e..0000000
--- a/.idea/jarRepositories.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index 5ac489c..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,47 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index 8c565d6..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 35eb1dd..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index dafe122..3bba2f4 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -10,7 +10,7 @@ android {
targetSdkVersion 31
versionCode 1
versionName "1.0"
- testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
+ testInstrumentationRunner 'com.appttude.h_mal.farmr.application.TestRunner'
vectorDrawables.useSupportLibrary = true
}
buildTypes {
@@ -19,6 +19,7 @@ android {
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
+ useLibrary 'android.test.mock'
}
dependencies {
@@ -34,9 +35,33 @@ dependencies {
implementation 'androidx.activity:activity-ktx:1.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'
implementation 'androidx.preference:preference:1.2.1'
- testImplementation 'junit:junit:4.12'
+ implementation 'com.ajts.androidmads.SQLite2Excel:library:1.0.2'
+ / * Unit testing * /
+ testImplementation 'junit:junit:4.13.2'
+ androidTestImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"
+ testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"
+ implementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
androidTestImplementation 'androidx.test:core-ktx:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
+ / * mockito and livedata testing * /
+ testImplementation 'org.mockito:mockito-inline:2.13.0'
+ testImplementation 'androidx.arch.core:core-testing:2.1.0'
+ / * MockK * /
+ def mockk_ver = "1.10.5"
+ testImplementation "io.mockk:mockk:$mockk_ver"
+ androidTestImplementation "io.mockk:mockk-android:$mockk_ver"
+ / * Android Espresso * /
+ def testJunitVersion = "1.1.5"
+ def testRunnerVersion = "1.5.2"
+ def espressoVersion = "3.5.1"
+ androidTestImplementation "androidx.test.ext:junit:$testJunitVersion"
+ androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
+ androidTestImplementation "androidx.test.espresso.idling:idling-concurrent:$espressoVersion"
+ implementation "androidx.test.espresso:espresso-idling-resource:$espressoVersion"
+ androidTestImplementation "androidx.test:runner:$testRunnerVersion"
+ androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion"
+ androidTestImplementation "androidx.test.espresso:espresso-intents:$espressoVersion"
+ androidTestImplementation "org.hamcrest:hamcrest:2.2"
/ * Room database * /
def room_version = "2.4.3"
implementation "androidx.room:room-runtime:$room_version"
@@ -48,5 +73,4 @@ dependencies {
implementation "org.kodein.di:kodein-di-framework-android-x:$kodein_version"
/ * jxl * /
implementation 'net.sourceforge.jexcelapi:jxl:2.6.12'
-
}
diff --git a/app/src/androidTest/java/com/appttude/h_mal/farmr/application/TestAppClass.kt b/app/src/androidTest/java/com/appttude/h_mal/farmr/application/TestAppClass.kt
new file mode 100644
index 0000000..616c325
--- /dev/null
+++ b/app/src/androidTest/java/com/appttude/h_mal/farmr/application/TestAppClass.kt
@@ -0,0 +1,38 @@
+package com.appttude.h_mal.farmr.application
+
+import androidx.test.espresso.IdlingRegistry
+import androidx.test.espresso.idling.CountingIdlingResource
+import androidx.test.platform.app.InstrumentationRegistry
+import com.appttude.h_mal.farmr.base.BaseApplication
+import com.appttude.h_mal.farmr.data.legacydb.LegacyDatabase
+import com.appttude.h_mal.farmr.data.prefs.PreferenceProvider
+import com.appttude.h_mal.farmr.model.Shift
+
+class TestAppClass : BaseApplication() {
+ private val idlingResources = CountingIdlingResource("Data_loader")
+
+ lateinit var database: LegacyDatabase
+ lateinit var preferenceProvider: PreferenceProvider
+
+ override fun onCreate() {
+ super.onCreate()
+ IdlingRegistry.getInstance().register(idlingResources)
+ }
+
+ override fun createDatabase(): LegacyDatabase {
+ database =
+ LegacyDatabase(InstrumentationRegistry.getInstrumentation().context.contentResolver)
+ return database
+ }
+
+ override fun createPrefs(): PreferenceProvider {
+ preferenceProvider = PreferenceProvider(this)
+ return preferenceProvider
+ }
+
+ fun addToDatabase(shift: Shift) = database.insertShiftDataIntoDatabase(shift)
+ fun addShiftsToDatabase(shifts: List) = shifts.forEach { addToDatabase(it) }
+ fun clearDatabase() = database.deleteAllShiftsInDatabase()
+ fun cleanPrefs() = preferenceProvider.clearPrefs()
+
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/appttude/h_mal/farmr/application/TestRunner.kt b/app/src/androidTest/java/com/appttude/h_mal/farmr/application/TestRunner.kt
new file mode 100644
index 0000000..ee712d4
--- /dev/null
+++ b/app/src/androidTest/java/com/appttude/h_mal/farmr/application/TestRunner.kt
@@ -0,0 +1,21 @@
+package com.appttude.h_mal.farmr.application
+
+import android.app.Application
+import android.content.Context
+import androidx.test.runner.AndroidJUnitRunner
+
+class TestRunner : AndroidJUnitRunner() {
+ @Throws(
+ InstantiationException::class,
+ IllegalAccessException::class,
+ ClassNotFoundException::class
+ )
+ override fun newApplication(
+ cl: ClassLoader?,
+ className: String?,
+ context: Context?
+ ): Application {
+ return super.newApplication(cl, TestAppClass::class.java.name, context)
+ }
+
+}
diff --git a/app/src/androidTest/java/com/appttude/h_mal/farmr/data/ShiftProviderTest.kt b/app/src/androidTest/java/com/appttude/h_mal/farmr/data/ShiftProviderTest.kt
index 67a4c51..7a11af7 100644
--- a/app/src/androidTest/java/com/appttude/h_mal/farmr/data/ShiftProviderTest.kt
+++ b/app/src/androidTest/java/com/appttude/h_mal/farmr/data/ShiftProviderTest.kt
@@ -19,6 +19,7 @@ import com.appttude.h_mal.farmr.data.legacydb.ShiftsContract.ShiftsEntry._ID
import com.appttude.h_mal.farmr.data.legacydb.ShiftProvider
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNull
+import org.junit.After
import org.junit.Rule
import org.junit.Test
@@ -31,6 +32,11 @@ class ShiftProviderTest {
private val contentResolver: ContentResolver
get() = providerRule.resolver
+ @After
+ fun tearDown() {
+ contentResolver.delete(CONTENT_URI, null, null)
+ }
+
@Test
fun insertEntry_queryEntry_assertEntry() {
// Arrange
diff --git a/app/src/androidTest/java/com/appttude/h_mal/farmr/data/legacydb/LegacyDatabaseTest.kt b/app/src/androidTest/java/com/appttude/h_mal/farmr/data/legacydb/LegacyDatabaseTest.kt
new file mode 100644
index 0000000..e539bdd
--- /dev/null
+++ b/app/src/androidTest/java/com/appttude/h_mal/farmr/data/legacydb/LegacyDatabaseTest.kt
@@ -0,0 +1,91 @@
+package com.appttude.h_mal.farmr.data.legacydb
+
+import androidx.test.rule.provider.ProviderTestRule
+import com.appttude.h_mal.farmr.model.Shift
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class LegacyDatabaseTest {
+ @get:Rule
+ val providerRule: ProviderTestRule = ProviderTestRule
+ .Builder(ShiftProvider::class.java, ShiftsContract.CONTENT_AUTHORITY)
+ .build()
+
+ private lateinit var database: LegacyDatabase
+
+ @Before
+ fun setup() {
+ database = LegacyDatabase(providerRule.resolver)
+ }
+
+ @After
+ fun tearDown() {
+ database.deleteAllShiftsInDatabase()
+ }
+
+ @Test
+ fun insertShift_readShift_successfulRead() {
+ // Arrange
+ val shift = Shift("adsfadsf", "2020-12-12", 12f, 12f)
+
+ // Act
+ database.insertShiftDataIntoDatabase(shift)
+ val retrievedShift = database.readShiftsFromDatabase()?.first()
+
+ // Assert
+ assertEquals(retrievedShift?.description, shift.description)
+ assertEquals(retrievedShift?.date, shift.date)
+ assertEquals(retrievedShift?.units, shift.units)
+ assertEquals(retrievedShift?.rateOfPay, shift.rateOfPay)
+ }
+
+ @Test
+ fun insertShift_updateShift_successfulRead() {
+ // Arrange
+ val shift = Shift("adsfadsf", "2020-12-12", 12f, 12f)
+ val updateShift = Shift("dasdads", "2020-11-12", 10f, 10f)
+
+ // Act
+ database.insertShiftDataIntoDatabase(shift)
+ val id = database.readShiftsFromDatabase()?.first()!!.id
+ database.updateShiftDataIntoDatabase(
+ id = id,
+ typeString = updateShift.type.type,
+ descriptionString = updateShift.description,
+ dateString = updateShift.date,
+ timeInString = updateShift.timeIn ?: "",
+ timeOutString = updateShift.timeOut ?: "",
+ duration = updateShift.duration ?: 0f,
+ breaks = updateShift.breakMins ?: 0,
+ units = updateShift.units!!,
+ payRate = updateShift.rateOfPay,
+ totalPay = updateShift.totalPay
+ )
+ val retrievedShift = database.readSingleShiftWithId(id)
+
+ // Assert
+ assertEquals(retrievedShift?.description, updateShift.description)
+ assertEquals(retrievedShift?.date, updateShift.date)
+ assertEquals(retrievedShift?.units, updateShift.units)
+ assertEquals(retrievedShift?.rateOfPay, updateShift.rateOfPay)
+ }
+
+ @Test
+ fun insertShift_deleteShift_databaseEmpty() {
+ // Arrange
+ val shift = Shift("adsfadsf", "2020-12-12", 12f, 12f)
+ val updateShift = Shift("dasdads", "2020-11-12", 10f, 10f)
+
+ // Act
+ database.insertShiftDataIntoDatabase(shift)
+ database.insertShiftDataIntoDatabase(updateShift)
+ val id = database.readShiftsFromDatabase()?.first()!!.id
+ database.deleteSingleShift(id)
+
+ // Assert
+ assertEquals(database.readShiftsFromDatabase()?.size, 1)
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/BaseTest.kt b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/BaseTest.kt
new file mode 100644
index 0000000..0536254
--- /dev/null
+++ b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/BaseTest.kt
@@ -0,0 +1,110 @@
+package com.appttude.h_mal.farmr.ui
+
+import android.Manifest
+import android.app.Activity
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import androidx.lifecycle.Lifecycle
+import androidx.test.core.app.ActivityScenario
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.UiController
+import androidx.test.espresso.ViewAction
+import androidx.test.espresso.assertion.ViewAssertions
+import androidx.test.espresso.matcher.RootMatchers.withDecorView
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.rule.GrantPermissionRule
+import com.appttude.h_mal.farmr.application.TestAppClass
+import com.appttude.h_mal.farmr.di.ShiftApplication
+import com.appttude.h_mal.farmr.ui.utils.getShifts
+import kotlinx.coroutines.runBlocking
+import org.hamcrest.Matcher
+import org.hamcrest.Matchers
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.kodein.di.android.kodein
+
+@Suppress("EmptyMethod")
+open class BaseTest(
+ private val activity: Class,
+ private val intentBundle: Bundle? = null,
+) {
+
+ lateinit var scenario: ActivityScenario
+ private lateinit var testApp: TestAppClass
+ private lateinit var testActivity: Activity
+ private lateinit var decorView: View
+
+ @get:Rule
+ var permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_EXTERNAL_STORAGE)
+
+ @Before
+ open fun setUp() {
+ val startIntent =
+ Intent(InstrumentationRegistry.getInstrumentation().targetContext, activity)
+ if (intentBundle != null) {
+ startIntent.replaceExtras(intentBundle)
+ }
+
+ testApp =
+ InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as TestAppClass
+ kodein(testApp)
+ runBlocking {
+ beforeLaunch()
+ }
+
+ scenario = ActivityScenario.launch(startIntent)
+ scenario.onActivity {
+ testApp =
+ InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as TestAppClass
+ onLaunch()
+ decorView = it.window.decorView
+ testActivity = it
+ }
+ afterLaunch()
+ }
+
+ fun getActivity() = testActivity
+
+ @After
+ fun tearDown() {
+ testFinished()
+ }
+
+ open fun beforeLaunch() {}
+ open fun onLaunch() {}
+ open fun afterLaunch() {}
+ open fun testFinished() {}
+
+ fun waitFor(delay: Long) {
+ Espresso.onView(ViewMatchers.isRoot()).perform(object : ViewAction {
+ override fun getConstraints(): Matcher = ViewMatchers.isRoot()
+ override fun getDescription(): String = "wait for $delay milliseconds"
+ override fun perform(uiController: UiController, v: View?) {
+ uiController.loopMainThreadForAtLeast(delay)
+ }
+ })
+ }
+
+ @Suppress("DEPRECATION")
+ fun checkToastMessage(message: String) {
+ Espresso.onView(ViewMatchers.withText(message)).inRoot(withDecorView(Matchers.not(decorView)))
+ .check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ waitFor(3500)
+ }
+ }
+
+ fun navigateBack() = Espresso.pressBack()
+
+ fun addRandomShifts() {
+ testApp.addShiftsToDatabase(getShifts())
+ }
+
+ fun clearDataBase() = testApp.clearDatabase()
+ fun clearPrefs() = testApp.cleanPrefs()
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/BaseTestRobot.kt b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/BaseTestRobot.kt
new file mode 100644
index 0000000..a85098f
--- /dev/null
+++ b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/BaseTestRobot.kt
@@ -0,0 +1,219 @@
+package com.appttude.h_mal.farmr.ui
+
+import android.content.res.Resources
+import android.view.View
+import android.widget.DatePicker
+import android.widget.TimePicker
+import androidx.annotation.StringRes
+import androidx.appcompat.widget.AppCompatButton
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import androidx.test.espresso.Espresso.onData
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
+import androidx.test.espresso.UiController
+import androidx.test.espresso.ViewAction
+import androidx.test.espresso.ViewInteraction
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.action.ViewActions.swipeDown
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.contrib.PickerActions
+import androidx.test.espresso.contrib.RecyclerViewActions
+import androidx.test.espresso.matcher.RootMatchers.isDialog
+import androidx.test.espresso.matcher.ViewMatchers.*
+import androidx.test.platform.app.InstrumentationRegistry
+import com.appttude.h_mal.farmr.ui.utils.EspressoHelper.waitForView
+import org.hamcrest.CoreMatchers.allOf
+import org.hamcrest.CoreMatchers.anything
+import org.hamcrest.CoreMatchers.equalTo
+import org.hamcrest.Matcher
+import org.hamcrest.Matchers
+
+@SuppressWarnings("unused")
+open class BaseTestRobot {
+
+ fun fillEditText(resId: Int, text: String?): ViewInteraction =
+ onView(withId(resId)).perform(
+ ViewActions.replaceText(text),
+ ViewActions.closeSoftKeyboard()
+ )
+
+ fun clickButton(resId: Int): ViewInteraction =
+ onView((withId(resId))).perform(click())
+
+// fun clickMenu(menuId: Int): ViewInteraction = onView()
+
+ fun matchView(resId: Int): ViewInteraction = onView(withId(resId))
+
+ fun matchViewWaitFor(resId: Int): ViewInteraction = waitForView(withId(resId))
+
+ fun matchDisplayed(resId: Int): ViewInteraction = matchView(resId).check(matches(isDisplayed()))
+
+ fun matchText(viewInteraction: ViewInteraction, text: String): ViewInteraction = viewInteraction
+ .check(matches(withText(text)))
+
+ fun matchText(viewId: Int, textId: Int): ViewInteraction = onView(withId(viewId))
+ .check(matches(withText(textId)))
+
+ fun matchText(resId: Int, text: String): ViewInteraction = matchText(matchView(resId), text)
+
+ fun clickListItem(listRes: Int, position: Int) {
+ onData(anything())
+ .inAdapterView(allOf(withId(listRes)))
+ .atPosition(position).perform(click())
+ }
+
+ fun clickOnMenuItem(menuId: Int) {
+ openActionBarOverflowOrOptionsMenu(InstrumentationRegistry.getInstrumentation().context)
+ onView(withText(menuId)).perform(click())
+ }
+
+ fun clickDialogButton(text: String) {
+ onView(withText(text)).inRoot(isDialog())
+ .check(matches(isDisplayed()))
+ .perform(click());
+ }
+
+ fun scrollToRecyclerItem(recyclerId: Int, text: String): ViewInteraction? {
+ return matchView(recyclerId)
+ .perform(
+ // scrollTo will fail the test if no item matches.
+ RecyclerViewActions.scrollTo(
+ hasDescendant(withText(text))
+ )
+ )
+ }
+
+ fun scrollToRecyclerItem(
+ recyclerId: Int,
+ resIdForString: Int
+ ): ViewInteraction? {
+ return matchView(recyclerId)
+ .perform(
+ // scrollTo will fail the test if no item matches.
+ RecyclerViewActions.scrollTo(
+ hasDescendant(withText(resIdForString))
+ )
+ )
+ }
+
+ fun scrollToRecyclerItemByPosition(
+ recyclerId: Int,
+ position: Int
+ ): ViewInteraction? {
+ return matchView(recyclerId)
+ .perform(
+ // scrollTo will fail the test if no item matches.
+ RecyclerViewActions.scrollToPosition(position)
+ )
+ }
+
+ fun clickViewInRecycler(recyclerId: Int, resIdForString: Int) {
+ matchView(recyclerId)
+ .perform(
+ // scrollTo will fail the test if no item matches.
+ RecyclerViewActions.actionOnItem(
+ hasDescendant(withText(resIdForString)),
+ click()
+ )
+ )
+ }
+
+ fun clickRecyclerAtPosition(recyclerId: Int, position: Int) {
+ matchView(recyclerId)
+ .perform(
+ // scrollTo will fail the test if no item matches.
+ RecyclerViewActions.scrollToPosition(position),
+ RecyclerViewActions.actionOnItemAtPosition(position, click()),
+ )
+ }
+
+ fun clickViewInRecyclerAtPosition(recyclerId: Int, position: Int, subViewId: Int) {
+ matchView(recyclerId)
+ .perform(
+ // scrollTo will fail the test if no item matches.
+ RecyclerViewActions.scrollToPosition(position),
+ RecyclerViewActions.actionOnItemAtPosition(position, object : ViewAction {
+ override fun getDescription(): String {
+ return "click on subview in RecyclerView at position: $position"
+ }
+
+ override fun getConstraints(): Matcher {
+ return Matchers.allOf(
+ isAssignableFrom(
+ RecyclerView::class.java
+ ), isDisplayed()
+ )
+ }
+
+ override fun perform(uiController: UiController?, view: View?) {
+ view?.findViewById(subViewId)?.performClick()
+ }
+
+ }),
+ )
+ }
+
+ fun clickOnRecyclerItemWithText(recyclerId: Int, text: String) {
+ matchView(recyclerId).perform(
+ // scrollTo will fail the test if no item matches.
+ RecyclerViewActions.scrollTo(
+ hasDescendant(withText(text))
+ ),
+ RecyclerViewActions.actionOnItem(
+ hasDescendant(withText(text)),
+ click()
+ )
+ )
+ }
+
+ fun swipeDown(resId: Int): ViewInteraction =
+ onView(withId(resId)).perform(swipeDown())
+
+ fun getStringFromResource(@StringRes resId: Int): String =
+ Resources.getSystem().getString(resId)
+
+ fun pullToRefresh(resId: Int) {
+ onView(allOf(withId(resId), isDisplayed())).perform(swipeDown())
+ }
+
+ fun selectDateInPicker(year: Int, month: Int, day: Int) {
+ onView(withClassName(equalTo(DatePicker::class.java.name))).perform(
+ PickerActions.setDate(
+ year,
+ month,
+ day
+ )
+ )
+ onView(
+ allOf(
+ withClassName(equalTo(AppCompatButton::class.java.name)),
+ withText("OK")
+ )
+ ).perform(
+ click()
+ )
+ }
+
+ fun selectTextInSpinner(id: Int, text: String) {
+ clickButton(id)
+ onView(withSpinnerText(text)).perform(click())
+ }
+
+ fun selectTimeInPicker(hours: Int, minutes: Int) {
+ onView(withClassName(equalTo(TimePicker::class.java.name))).perform(
+ PickerActions.setTime(
+ hours, minutes
+ )
+ )
+ onView(
+ allOf(
+ withClassName(equalTo(AppCompatButton::class.java.name)),
+ withText("OK")
+ )
+ ).perform(
+ click()
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/robots/AddItemScreenRobot.kt b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/robots/AddItemScreenRobot.kt
new file mode 100644
index 0000000..c28cfb1
--- /dev/null
+++ b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/robots/AddItemScreenRobot.kt
@@ -0,0 +1,41 @@
+package com.appttude.h_mal.farmr.ui.robots
+
+import com.appttude.h_mal.farmr.R
+import com.appttude.h_mal.farmr.model.ShiftType
+import com.appttude.h_mal.farmr.ui.BaseTestRobot
+
+fun addScreen(func: AddItemScreenRobot.() -> Unit) = AddItemScreenRobot().apply { func() }
+class AddItemScreenRobot : BaseTestRobot() {
+
+ fun clickShiftType(type: ShiftType) {
+ when (type) {
+ ShiftType.HOURLY -> clickButton(R.id.hourly)
+ ShiftType.PIECE -> clickButton(R.id.piecerate)
+ }
+ }
+
+ fun setDescription(text: String?) = fillEditText(R.id.locationEditText, text)
+ fun setDate(year: Int, month: Int, day: Int) {
+ clickButton(R.id.dateEditText)
+ selectDateInPicker(year, month, day)
+ }
+
+ fun setTimeIn(hour: Int, minutes: Int) {
+ clickButton(R.id.timeInEditText)
+ selectTimeInPicker(hour, minutes)
+ }
+
+ fun setTimeOut(hour: Int, minutes: Int) {
+ clickButton(R.id.timeOutEditText)
+ selectTimeInPicker(hour, minutes)
+ }
+
+ fun setBreakTime(mins: Int) = fillEditText(R.id.breakEditText, mins.toString())
+ fun setUnits(units: Float) = fillEditText(R.id.unitET, units.toString())
+ fun setRateOfPay(rateOfPay: Float) = fillEditText(R.id.payrateET, rateOfPay.toString())
+ fun submit() = clickButton(R.id.submit)
+
+ fun assertTotalPay(pay: String) = matchText(R.id.totalpayval, pay)
+ fun assertDuration(duration: String) = matchText(R.id.ShiftDuration, duration)
+
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/robots/FilterScreenRobot.kt b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/robots/FilterScreenRobot.kt
new file mode 100644
index 0000000..5907ac6
--- /dev/null
+++ b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/robots/FilterScreenRobot.kt
@@ -0,0 +1,29 @@
+package com.appttude.h_mal.farmr.ui.robots
+
+import com.appttude.h_mal.farmr.R
+import com.appttude.h_mal.farmr.ui.BaseTestRobot
+import com.appttude.h_mal.farmr.model.ShiftType
+
+fun filterScreen(func: FilterScreenRobot.() -> Unit) = FilterScreenRobot().apply { func() }
+class FilterScreenRobot : BaseTestRobot() {
+
+ fun setDescription(text: String?) = fillEditText(R.id.filterLocationEditText, text)
+
+ fun setDateIn(year: Int, month: Int, day: Int) {
+ clickButton(R.id.fromdateInEditText)
+ selectDateInPicker(year, month, day)
+ }
+
+ fun setDateOut(year: Int, month: Int, day: Int) {
+ clickButton(R.id.filterDateOutEditText)
+ selectDateInPicker(year, month, day)
+ }
+
+ fun setType(type: ShiftType?) = when(type) {
+ ShiftType.HOURLY -> selectTextInSpinner(R.id.TypeFilterEditText, type.type)
+ ShiftType.PIECE -> selectTextInSpinner(R.id.TypeFilterEditText, type.type)
+ null -> selectTextInSpinner(R.id.TypeFilterEditText, "")
+ }
+ fun submit() = clickButton(R.id.submitFiltered)
+
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/robots/HomeScreenRobot.kt b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/robots/HomeScreenRobot.kt
new file mode 100644
index 0000000..4584b4a
--- /dev/null
+++ b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/robots/HomeScreenRobot.kt
@@ -0,0 +1,29 @@
+package com.appttude.h_mal.farmr.ui.robots
+
+import androidx.test.espresso.matcher.RootMatchers.isDialog
+import com.appttude.h_mal.farmr.R
+import com.appttude.h_mal.farmr.base.BaseRecyclerAdapter.CurrentViewHolder
+import com.appttude.h_mal.farmr.model.Order
+import com.appttude.h_mal.farmr.model.Sortable
+import com.appttude.h_mal.farmr.ui.BaseTestRobot
+
+fun homeScreen(func: HomeScreenRobot.() -> Unit) = HomeScreenRobot().apply { func() }
+class HomeScreenRobot : BaseTestRobot() {
+
+ fun clickOnItemWithText(text: String) = clickOnRecyclerItemWithText(R.id.list_item_view, text)
+ fun clickOnItemAtPosition(position: Int) = clickRecyclerAtPosition(R.id.list_item_view, position)
+ fun clickOnEdit(position: Int) = clickViewInRecyclerAtPosition(R.id.list_item_view, position, R.id.imageView)
+ fun clickFab() = clickButton(R.id.fab1)
+ fun clickOnInfoIcon() = clickButton(R.id.action_favorite)
+ fun clickFilterInMenu() = clickOnMenuItem(R.string.filter)
+ fun clickClearFilterInMenu() = clickOnMenuItem(R.string.clear)
+ fun clickSortInMenu() = clickOnMenuItem(R.string.sort)
+
+ fun applySort(sortable: Sortable, order: Order = Order.ASCENDING) {
+ clickSortInMenu()
+ val label = sortable.label
+ clickDialogButton(label)
+ val orderLabel = order.label
+ clickDialogButton(orderLabel)
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/robots/ViewItemScreenRobot.kt b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/robots/ViewItemScreenRobot.kt
new file mode 100644
index 0000000..0dbcdad
--- /dev/null
+++ b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/robots/ViewItemScreenRobot.kt
@@ -0,0 +1,33 @@
+package com.appttude.h_mal.farmr.ui.robots
+
+import com.appttude.h_mal.farmr.R
+import com.appttude.h_mal.farmr.ui.BaseTestRobot
+import com.appttude.h_mal.farmr.model.ShiftType
+
+fun viewScreen(func: ViewItemScreenRobot.() -> Unit) = ViewItemScreenRobot().apply { func() }
+class ViewItemScreenRobot : BaseTestRobot() {
+
+ fun matchShiftType(type: ShiftType) {
+ when (type) {
+ ShiftType.HOURLY -> matchText(R.id.details_shift, type.type)
+ ShiftType.PIECE -> matchText(R.id.details_shift, type.type)
+ }
+ }
+
+ fun matchDescription(text: String) = matchText(R.id.details_desc, text)
+ fun matchDate(date: String) {
+ matchText(R.id.details_date, date)
+ }
+
+ fun matchTime(timeIn: String, timeOut: String) {
+ matchText(R.id.details_time, "$timeIn-$timeOut")
+ }
+
+ fun matchBreakTime(mins: Int) = matchText(R.id.details_breaks, mins.toString())
+ fun matchUnits(units: Float) = fillEditText(R.id.details_units, units.toString())
+ fun matchRateOfPay(rateOfPay: Float) = fillEditText(R.id.details_pay_rate, rateOfPay.toString())
+ fun matchTotalPay(pay: String) = matchText(R.id.details_totalpay, pay)
+ fun matchDuration(duration: String) = matchText(R.id.details_duration, duration)
+
+ fun clickEdit() = clickButton(R.id.details_edit)
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/tests/ShiftTests.kt b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/tests/ShiftTests.kt
new file mode 100644
index 0000000..e5f4a2f
--- /dev/null
+++ b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/tests/ShiftTests.kt
@@ -0,0 +1,142 @@
+package com.appttude.h_mal.farmr.ui.tests
+
+import com.appttude.h_mal.farmr.model.Order
+import com.appttude.h_mal.farmr.model.ShiftType
+import com.appttude.h_mal.farmr.model.Sortable
+import com.appttude.h_mal.farmr.ui.BaseTest
+import com.appttude.h_mal.farmr.ui.MainActivity
+import com.appttude.h_mal.farmr.ui.robots.addScreen
+import com.appttude.h_mal.farmr.ui.robots.filterScreen
+import com.appttude.h_mal.farmr.ui.robots.homeScreen
+import com.appttude.h_mal.farmr.ui.robots.viewScreen
+import com.appttude.h_mal.farmr.utils.ID
+import org.junit.Test
+
+class ShiftTests : BaseTest(MainActivity::class.java) {
+
+ override fun afterLaunch() {
+ super.afterLaunch()
+ addRandomShifts()
+
+ // Content resolver hard to mock
+ // Dirty technique to have a populated list
+ homeScreen {
+ clickFab()
+ navigateBack()
+ }
+ }
+
+ override fun testFinished() {
+ super.testFinished()
+ clearDataBase()
+ clearPrefs()
+ }
+
+ // Add a shift successfully
+ @Test
+ fun openAddScreen_addNewShift_newShiftCreated() {
+ homeScreen {
+ clickFab()
+ }
+ addScreen {
+ setDescription("This is a description")
+ setDate(2023, 2, 11)
+ clickShiftType(ShiftType.HOURLY)
+ setTimeIn(12, 0)
+ setTimeOut(14, 30)
+ setBreakTime(30)
+ setRateOfPay(10f)
+ assertDuration("2.0 hours")
+ assertTotalPay("£20.00")
+ submit()
+ }
+ homeScreen {
+ clickOnItemWithText("This is a description")
+ }
+ }
+
+ // Edit a shift successfully
+ @Test
+ fun test2() {
+ homeScreen {
+ clickOnEdit(0)
+ }
+ addScreen {
+ setDescription("Edited this shift")
+ setTimeIn(12, 0)
+ setTimeOut(14, 30)
+ setBreakTime(30)
+ setRateOfPay(20f)
+ assertDuration("2.0 hours")
+ assertTotalPay("£40.00")
+ submit()
+ }
+ homeScreen {
+ clickOnItemWithText("Edited this shift")
+ }
+ viewScreen {
+ matchDescription("Edited this shift")
+ matchDuration("2 Hours 0 Minutes (+ 30 minutes break)")
+ matchTotalPay("2.0 Hours @ £20.00 per Hour\nEquals: £40.00")
+ }
+ }
+
+ // filter the list with date from
+ @Test
+ fun test3() {
+ homeScreen {
+ applySort(Sortable.TYPE, Order.DESCENDING)
+ clickOnItemAtPosition(0)
+ viewScreen {
+ matchDescription("Day five")
+ matchShiftType(ShiftType.PIECE)
+ }
+ }
+ }
+
+ // filter the list with date to
+ @Test
+ fun test4() {
+ homeScreen {
+ clickFilterInMenu()
+ }
+ filterScreen {
+ setDateIn(2023,8,3)
+ setDateOut(2023,8,6)
+ submit()
+ }
+ homeScreen {
+ clickOnItemAtPosition(0)
+ }
+ }
+
+ // Add a shift as piece rate
+ @Test
+ fun test5() {
+ homeScreen {
+ clickFab()
+ }
+ addScreen {
+ setDescription("This is a description")
+ setDate(2023, 2, 11)
+ clickShiftType(ShiftType.PIECE)
+ setRateOfPay(10f)
+ setUnits(1f)
+ assertTotalPay("£10.00")
+ submit()
+ }
+ homeScreen {
+ clickOnItemWithText("This is a description")
+ }
+ }
+
+ // Validate the details screen
+ @Test
+ fun test6() {
+ }
+
+ // filter, sort, order and then reset
+ @Test
+ fun test7() {
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/utils/Constants.kt b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/utils/Constants.kt
new file mode 100644
index 0000000..ad69acd
--- /dev/null
+++ b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/utils/Constants.kt
@@ -0,0 +1 @@
+package com.appttude.h_mal.farmr.ui.utils
diff --git a/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/utils/DataHelper.kt b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/utils/DataHelper.kt
new file mode 100644
index 0000000..0e05bb6
--- /dev/null
+++ b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/utils/DataHelper.kt
@@ -0,0 +1,103 @@
+package com.appttude.h_mal.farmr.ui.utils
+
+import com.appttude.h_mal.farmr.model.Shift
+import com.appttude.h_mal.farmr.model.ShiftType
+
+fun getShifts() = listOf(
+ Shift(
+ ShiftType.HOURLY,
+ "Day one",
+ "2023-08-01",
+ "12:00",
+ "13:00",
+ 1f,
+ 0,
+ 0f,
+ 10f,
+ 10f
+ ),
+ Shift(
+ ShiftType.HOURLY,
+ "Day two",
+ "2023-08-02",
+ "12:00",
+ "13:00",
+ 1f,
+ 0,
+ 0f,
+ 10f,
+ 10f
+ ),
+ Shift(
+ ShiftType.HOURLY,
+ "Day three",
+ "2023-08-03",
+ "12:00",
+ "13:00",
+ 1f,
+ 30,
+ 0f,
+ 10f,
+ 5f
+ ),
+ Shift(
+ ShiftType.HOURLY,
+ "Day four",
+ "2023-08-04",
+ "12:00",
+ "13:00",
+ 1f,
+ 30,
+ 0f,
+ 10f,
+ 5f
+ ),
+ Shift(
+ ShiftType.PIECE,
+ "Day five",
+ "2023-08-05",
+ "",
+ "",
+ 0f,
+ 0,
+ 1f,
+ 10f,
+ 10f
+ ),
+ Shift(
+ ShiftType.PIECE,
+ "Day six",
+ "2023-08-06",
+ "",
+ "",
+ 0f,
+ 0,
+ 1f,
+ 10f,
+ 10f
+ ),
+ Shift(
+ ShiftType.PIECE,
+ "Day seven",
+ "2023-08-07",
+ "",
+ "",
+ 0f,
+ 0,
+ 1f,
+ 10f,
+ 10f
+ ),
+ Shift(
+ ShiftType.PIECE,
+ "Day eight",
+ "2023-08-08",
+ "",
+ "",
+ 0f,
+ 0,
+ 1f,
+ 10f,
+ 10f
+ )
+)
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/utils/EspressoHelper.kt b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/utils/EspressoHelper.kt
new file mode 100644
index 0000000..a2ebc7a
--- /dev/null
+++ b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/utils/EspressoHelper.kt
@@ -0,0 +1,123 @@
+package com.appttude.h_mal.farmr.ui.utils
+
+import android.os.SystemClock.sleep
+import android.view.View
+import android.widget.CheckBox
+import android.widget.Checkable
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.NoMatchingViewException
+import androidx.test.espresso.UiController
+import androidx.test.espresso.ViewAction
+import androidx.test.espresso.ViewInteraction
+import androidx.test.espresso.matcher.ViewMatchers.isRoot
+import androidx.test.espresso.util.TreeIterables
+import org.hamcrest.BaseMatcher
+import org.hamcrest.CoreMatchers.isA
+import org.hamcrest.Description
+import org.hamcrest.Matcher
+
+
+object EspressoHelper {
+
+ /**
+ * Perform action of waiting for a certain view within a single root view
+ * @param viewMatcher Generic Matcher used to find our view
+ */
+ fun searchFor(viewMatcher: Matcher): ViewAction {
+
+ return object : ViewAction {
+
+ override fun getConstraints(): Matcher = isRoot()
+ override fun getDescription(): String {
+ return "searching for view $this in the root view"
+ }
+
+ override fun perform(uiController: UiController, view: View) {
+ var tries = 0
+ val childViews: Iterable = TreeIterables.breadthFirstViewTraversal(view)
+
+ // Look for the match in the tree of child views
+ childViews.forEach {
+ tries++
+ if (viewMatcher.matches(it)) {
+ // found the view
+ return
+ }
+ }
+
+ throw NoMatchingViewException.Builder()
+ .withRootView(view)
+ .withViewMatcher(viewMatcher)
+ .build()
+ }
+ }
+ }
+
+ /**
+ * Performs an action to check/uncheck a checkbox
+ *
+ */
+ fun setChecked(checked: Boolean): ViewAction {
+ return object : ViewAction {
+ override fun getConstraints(): BaseMatcher {
+ return object : BaseMatcher() {
+ override fun describeTo(description: Description?) {}
+
+ override fun matches(actual: Any?): Boolean {
+ return isA(CheckBox::class.java).matches(actual)
+ }
+ }
+ }
+
+ override fun getDescription(): String {
+ return ""
+ }
+
+ override fun perform(uiController: UiController, view: View) {
+ val checkableView = view as Checkable
+ checkableView.isChecked = checked
+ }
+ }
+ }
+
+ /**
+ * Perform action of implicitly waiting for a certain view.
+ * This differs from EspressoExtensions.searchFor in that,
+ * upon failure to locate an element, it will fetch a new root view
+ * in which to traverse searching for our @param match
+ *
+ * @param viewMatcher ViewMatcher used to find our view
+ */
+ fun waitForView(
+ viewMatcher: Matcher,
+ waitMillis: Int = 5000,
+ waitMillisPerTry: Long = 100
+ ): ViewInteraction {
+
+ // Derive the max tries
+ val maxTries = waitMillis / waitMillisPerTry.toInt()
+
+ var tries = 0
+
+ for (i in 0..maxTries)
+ try {
+ // Track the amount of times we've tried
+ tries++
+
+ // Search the root for the view
+ onView(isRoot()).perform(searchFor(viewMatcher))
+
+ // If we're here, we found our view. Now return it
+ return onView(viewMatcher)
+
+ } catch (e: Exception) {
+
+ if (tries == maxTries) {
+ throw e
+ }
+ sleep(waitMillisPerTry)
+ }
+
+ throw Exception("Error finding a view matching $viewMatcher")
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/utils/TestUtils.kt b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/utils/TestUtils.kt
new file mode 100644
index 0000000..d398697
--- /dev/null
+++ b/app/src/androidTest/java/com/appttude/h_mal/farmr/ui/utils/TestUtils.kt
@@ -0,0 +1,32 @@
+package com.appttude.h_mal.farmr.ui.utils
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Observer
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+
+fun LiveData.getOrAwaitValue(
+ time: Long = 2,
+ timeUnit: TimeUnit = TimeUnit.SECONDS
+): T {
+ var data: T? = null
+ val latch = CountDownLatch(1)
+ val observer = object : Observer {
+ override fun onChanged(o: T?) {
+ data = o
+ latch.countDown()
+ this@getOrAwaitValue.removeObserver(this)
+ }
+ }
+
+ this.observeForever(observer)
+
+ // Don't wait indefinitely if the LiveData is not set.
+ if (!latch.await(time, timeUnit)) {
+ throw TimeoutException("LiveData value was never set.")
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ return data as T
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/base/BaseActivity.kt b/app/src/main/java/com/appttude/h_mal/farmr/base/BaseActivity.kt
index 3960cf5..6ddc382 100644
--- a/app/src/main/java/com/appttude/h_mal/farmr/base/BaseActivity.kt
+++ b/app/src/main/java/com/appttude/h_mal/farmr/base/BaseActivity.kt
@@ -1,26 +1,10 @@
package com.appttude.h_mal.farmr.base
import android.content.Intent
-import androidx.activity.ComponentActivity
import androidx.appcompat.app.AppCompatActivity
-import androidx.lifecycle.ViewModelLazy
import com.appttude.h_mal.farmr.utils.displayToast
-import com.appttude.h_mal.farmr.utils.getGenericClassAt
-import com.appttude.h_mal.farmr.viewmodel.ApplicationViewModelFactory
-import org.kodein.di.KodeinAware
-import org.kodein.di.android.kodein
-import org.kodein.di.generic.instance
-abstract class BaseActivity : AppCompatActivity(), KodeinAware {
-
- override val kodein by kodein()
- private val factory by instance()
-
- val viewModel: V by getViewModel()
-
- private fun getViewModel(): Lazy =
- ViewModelLazy(getGenericClassAt(0), storeProducer = { viewModelStore },
- factoryProducer = { factory } )
+abstract class BaseActivity : AppCompatActivity() {
/**
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/base/BaseApplication.kt b/app/src/main/java/com/appttude/h_mal/farmr/base/BaseApplication.kt
new file mode 100644
index 0000000..055e5f3
--- /dev/null
+++ b/app/src/main/java/com/appttude/h_mal/farmr/base/BaseApplication.kt
@@ -0,0 +1,31 @@
+package com.appttude.h_mal.farmr.base
+
+import android.app.Application
+import com.appttude.h_mal.farmr.data.RepositoryImpl
+import com.appttude.h_mal.farmr.data.legacydb.LegacyDatabase
+import com.appttude.h_mal.farmr.data.prefs.PreferenceProvider
+import com.appttude.h_mal.farmr.viewmodel.ApplicationViewModelFactory
+import org.kodein.di.Kodein
+import org.kodein.di.KodeinAware
+import org.kodein.di.android.x.androidXModule
+import org.kodein.di.generic.bind
+import org.kodein.di.generic.instance
+import org.kodein.di.generic.provider
+import org.kodein.di.generic.singleton
+
+abstract class BaseApplication() : Application(), KodeinAware {
+
+ // Kodein creation of modules to be retrieve within the app
+ override val kodein = Kodein.lazy {
+ import(androidXModule(this@BaseApplication))
+
+ bind() from singleton { createDatabase() }
+ bind() from singleton { createPrefs() }
+ bind() from singleton { RepositoryImpl(instance(), instance()) }
+
+ bind() from provider { ApplicationViewModelFactory(instance()) }
+ }
+
+ abstract fun createDatabase(): LegacyDatabase
+ abstract fun createPrefs(): PreferenceProvider
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/base/BaseFragment.kt b/app/src/main/java/com/appttude/h_mal/farmr/base/BaseFragment.kt
index a939028..3a25089 100644
--- a/app/src/main/java/com/appttude/h_mal/farmr/base/BaseFragment.kt
+++ b/app/src/main/java/com/appttude/h_mal/farmr/base/BaseFragment.kt
@@ -5,11 +5,13 @@ import android.view.View
import androidx.annotation.LayoutRes
import androidx.fragment.app.Fragment
import androidx.fragment.app.createViewModelLazy
+import androidx.lifecycle.ViewModelLazy
import com.appttude.h_mal.farmr.model.ViewState
import com.appttude.h_mal.farmr.utils.getGenericClassAt
import com.appttude.h_mal.farmr.utils.popBackStack
import com.appttude.h_mal.farmr.viewmodel.ApplicationViewModelFactory
import org.kodein.di.KodeinAware
+import org.kodein.di.android.kodein
import org.kodein.di.android.x.kodein
import org.kodein.di.generic.instance
import kotlin.properties.Delegates
@@ -21,14 +23,13 @@ abstract class BaseFragment(@LayoutRes contentLayoutId: Int)
override val kodein by kodein()
private val factory by instance()
- val viewModel: V by getActivityViewModel()
+ val viewModel: V by getViewModel()
- private fun getActivityViewModel() = createViewModelLazy(
- getGenericClassAt(0),
- { requireActivity().viewModelStore },
- { factory })
+ private fun getViewModel(): Lazy =
+ ViewModelLazy(getGenericClassAt(0), storeProducer = { viewModelStore },
+ factoryProducer = { factory } )
- var mActivity: BaseActivity<*>? = null
+ var mActivity: BaseActivity? = null
private var shortAnimationDuration by Delegates.notNull()
@@ -39,7 +40,7 @@ abstract class BaseFragment(@LayoutRes contentLayoutId: Int)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- mActivity = requireActivity() as BaseActivity<*>
+ mActivity = requireActivity() as BaseActivity
configureObserver()
}
@@ -75,7 +76,7 @@ abstract class BaseFragment(@LayoutRes contentLayoutId: Int)
}
fun setTitle(title: String) {
- (requireActivity() as BaseActivity<*>).setTitleInActionBar(title)
+ (requireActivity() as BaseActivity).setTitleInActionBar(title)
}
fun popBackStack() = mActivity?.popBackStack()
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/data/legacydb/ShiftsContract.kt b/app/src/main/java/com/appttude/h_mal/farmr/data/legacydb/ShiftsContract.kt
index 9fa0a96..c0195fe 100644
--- a/app/src/main/java/com/appttude/h_mal/farmr/data/legacydb/ShiftsContract.kt
+++ b/app/src/main/java/com/appttude/h_mal/farmr/data/legacydb/ShiftsContract.kt
@@ -3,12 +3,13 @@ package com.appttude.h_mal.farmr.data.legacydb
import android.content.ContentResolver
import android.net.Uri
import android.provider.BaseColumns
+import com.appttude.h_mal.farmr.BuildConfig
/**
* Created by h_mal on 26/12/2017.
*/
object ShiftsContract {
- const val CONTENT_AUTHORITY = "com.appttude.h_mal.farmr"
+ const val CONTENT_AUTHORITY = BuildConfig.APPLICATION_ID
val BASE_CONTENT_URI = Uri.parse("content://$CONTENT_AUTHORITY")
const val PATH_SHIFTS = "shifts"
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/data/prefs/PreferencesProvider.kt b/app/src/main/java/com/appttude/h_mal/farmr/data/prefs/PreferencesProvider.kt
index 99004a6..11481c1 100644
--- a/app/src/main/java/com/appttude/h_mal/farmr/data/prefs/PreferencesProvider.kt
+++ b/app/src/main/java/com/appttude/h_mal/farmr/data/prefs/PreferencesProvider.kt
@@ -13,8 +13,8 @@ const val SORT = "SORT"
const val ORDER = "ORDER"
const val DESCRIPTION = "DESCRIPTION"
-const val TIME_IN = "TIME_IN"
-const val TIME_OUT = "TIME_OUT"
+const val DATE_IN = "TIME_IN"
+const val DATE_OUT = "TIME_OUT"
const val TYPE = "TYPE"
class PreferenceProvider(
@@ -47,8 +47,8 @@ class PreferenceProvider(
) {
preference.edit()
.putString(DESCRIPTION, description)
- .putString(TIME_IN, timeIn)
- .putString(TIME_OUT, timeOut)
+ .putString(DATE_IN, timeIn)
+ .putString(DATE_OUT, timeOut)
.putString(TYPE, type)
.apply()
}
@@ -56,10 +56,14 @@ class PreferenceProvider(
fun getFilteringDetails(): Map {
return mapOf(
Pair(DESCRIPTION, preference.getString(DESCRIPTION, null)),
- Pair(TIME_IN, preference.getString(TIME_IN, null)),
- Pair(TIME_OUT, preference.getString(TIME_OUT, null)),
+ Pair(DATE_IN, preference.getString(DATE_IN, null)),
+ Pair(DATE_OUT, preference.getString(DATE_OUT, null)),
Pair(TYPE, preference.getString(TYPE, null))
)
}
+ fun clearPrefs() {
+ preference.edit().clear().apply()
+ }
+
}
\ No newline at end of file
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/di/ShiftApplication.kt b/app/src/main/java/com/appttude/h_mal/farmr/di/ShiftApplication.kt
index 0e1532f..3b0cd5f 100644
--- a/app/src/main/java/com/appttude/h_mal/farmr/di/ShiftApplication.kt
+++ b/app/src/main/java/com/appttude/h_mal/farmr/di/ShiftApplication.kt
@@ -1,11 +1,10 @@
package com.appttude.h_mal.farmr.di
-import android.app.Application
+import com.appttude.h_mal.farmr.base.BaseApplication
import com.appttude.h_mal.farmr.data.RepositoryImpl
import com.appttude.h_mal.farmr.data.legacydb.LegacyDatabase
import com.appttude.h_mal.farmr.data.prefs.PreferenceProvider
import com.appttude.h_mal.farmr.viewmodel.ApplicationViewModelFactory
-import com.appttude.h_mal.farmr.viewmodel.MainViewModel
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.android.x.androidXModule
@@ -14,15 +13,12 @@ import org.kodein.di.generic.instance
import org.kodein.di.generic.provider
import org.kodein.di.generic.singleton
-class ShiftApplication: Application(), KodeinAware {
- // Kodein creation of modules to be retrieve within the app
- override val kodein = Kodein.lazy {
- import(androidXModule(this@ShiftApplication))
+class ShiftApplication: BaseApplication() {
- bind() from singleton { LegacyDatabase(contentResolver) }
- bind() from singleton { PreferenceProvider(this@ShiftApplication) }
- bind() from singleton { RepositoryImpl(instance(), instance()) }
-
- bind() from provider { ApplicationViewModelFactory(instance()) }
+ override fun createDatabase(): LegacyDatabase {
+ return LegacyDatabase(contentResolver)
}
-}
\ No newline at end of file
+
+ override fun createPrefs() = PreferenceProvider(this)
+}
+
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/model/Sortable.kt b/app/src/main/java/com/appttude/h_mal/farmr/model/Sortable.kt
index f027a92..609fd20 100644
--- a/app/src/main/java/com/appttude/h_mal/farmr/model/Sortable.kt
+++ b/app/src/main/java/com/appttude/h_mal/farmr/model/Sortable.kt
@@ -11,5 +11,9 @@ enum class Sortable(val label: String) {
companion object {
val entries = Sortable.values()
+
+ fun getEnumByType(label: String): Sortable {
+ return Sortable.values().first { it.label == label }
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/ui/FilterDataFragment.kt b/app/src/main/java/com/appttude/h_mal/farmr/ui/FilterDataFragment.kt
index 745fb68..400c5a4 100644
--- a/app/src/main/java/com/appttude/h_mal/farmr/ui/FilterDataFragment.kt
+++ b/app/src/main/java/com/appttude/h_mal/farmr/ui/FilterDataFragment.kt
@@ -14,9 +14,9 @@ import com.appttude.h_mal.farmr.base.BaseFragment
import com.appttude.h_mal.farmr.model.ShiftType
import com.appttude.h_mal.farmr.model.Success
import com.appttude.h_mal.farmr.utils.setDatePicker
-import com.appttude.h_mal.farmr.viewmodel.MainViewModel
+import com.appttude.h_mal.farmr.viewmodel.FilterViewModel
-class FilterDataFragment : BaseFragment(R.layout.fragment_filter_data),
+class FilterDataFragment : BaseFragment(R.layout.fragment_filter_data),
AdapterView.OnItemSelectedListener, OnClickListener {
private val spinnerList: Array =
arrayOf("", ShiftType.HOURLY.type, ShiftType.PIECE.type)
@@ -26,10 +26,10 @@ class FilterDataFragment : BaseFragment(R.layout.fragment_filter_
private lateinit var dateToET: EditText
private lateinit var typeSpinner: Spinner
- private var description: String? = null
- private var dateFrom: String? = null
- private var dateTo: String? = null
- private var type: String? = null
+ private var descriptionString: String? = null
+ private var dateFromString: String? = null
+ private var dateToString: String? = null
+ private var typeString: String? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -47,21 +47,29 @@ class FilterDataFragment : BaseFragment(R.layout.fragment_filter_
val filterDetails = viewModel.getFiltrationDetails()
- filterDetails.let {
- LocationET.setText(it.description)
- dateFromET.setText(it.dateFrom)
- dateToET.setText(it.dateTo)
-
- it.type?.let { t ->
- val spinnerPosition: Int = adapter.getPosition(t)
+ filterDetails.run {
+ description?.let {
+ LocationET.setText(it)
+ descriptionString = it
+ }
+ dateFrom?.let {
+ dateFromET.setText(it)
+ dateFromString = it
+ }
+ dateTo?.let {
+ dateToET.setText(it)
+ dateToString = it
+ }
+ type?.let {
+ typeString = it
+ val spinnerPosition: Int = adapter.getPosition(it)
typeSpinner.setSelection(spinnerPosition)
}
-
}
- LocationET.doAfterTextChanged { description = it.toString() }
- dateFromET.setDatePicker { dateFrom = it }
- dateToET.setDatePicker { dateTo = it }
+ LocationET.doAfterTextChanged { descriptionString = it.toString() }
+ dateFromET.setDatePicker { dateFromString = it }
+ dateToET.setDatePicker { dateToString = it }
typeSpinner.onItemSelectedListener = this
submit.setOnClickListener(this)
@@ -73,7 +81,7 @@ class FilterDataFragment : BaseFragment(R.layout.fragment_filter_
position: Int,
id: Long
) {
- type = when (position) {
+ typeString = when (position) {
1 -> ShiftType.HOURLY.type
2 -> ShiftType.PIECE.type
else -> return
@@ -83,7 +91,7 @@ class FilterDataFragment : BaseFragment(R.layout.fragment_filter_
override fun onNothingSelected(parentView: AdapterView<*>?) {}
private fun submitFiltrationDetails() {
- viewModel.setFiltrationDetails(description, dateFrom, dateTo, type)
+ viewModel.applyFilters(descriptionString, dateFromString, dateToString, typeString)
}
override fun onClick(p0: View?) {
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/ui/FragmentAddItem.kt b/app/src/main/java/com/appttude/h_mal/farmr/ui/FragmentAddItem.kt
index eb74f10..889198e 100644
--- a/app/src/main/java/com/appttude/h_mal/farmr/ui/FragmentAddItem.kt
+++ b/app/src/main/java/com/appttude/h_mal/farmr/ui/FragmentAddItem.kt
@@ -18,6 +18,7 @@ import com.appttude.h_mal.farmr.model.Success
import com.appttude.h_mal.farmr.utils.ID
import com.appttude.h_mal.farmr.utils.createDialog
import com.appttude.h_mal.farmr.utils.displayToast
+import com.appttude.h_mal.farmr.utils.formatAsCurrencyString
import com.appttude.h_mal.farmr.utils.formatToTwoDpString
import com.appttude.h_mal.farmr.utils.hide
import com.appttude.h_mal.farmr.utils.popBackStack
@@ -26,8 +27,9 @@ import com.appttude.h_mal.farmr.utils.setTimePicker
import com.appttude.h_mal.farmr.utils.show
import com.appttude.h_mal.farmr.utils.validateField
import com.appttude.h_mal.farmr.viewmodel.MainViewModel
+import com.appttude.h_mal.farmr.viewmodel.SubmissionViewModel
-class FragmentAddItem : BaseFragment(R.layout.fragment_add_item),
+class FragmentAddItem : BaseFragment(R.layout.fragment_add_item),
RadioGroup.OnCheckedChangeListener, BackPressedListener {
private lateinit var mHourlyRadioButton: RadioButton
@@ -157,8 +159,8 @@ class FragmentAddItem : BaseFragment(R.layout.fragment_add_item),
mUnits = units
}
}
- mPayRateEditText.setText(rateOfPay.formatToTwoDpString())
- mTotalPayTextView.text = totalPay.formatToTwoDpString()
+ mPayRateEditText.setText(rateOfPay.formatAsCurrencyString())
+ mTotalPayTextView.text = totalPay.formatAsCurrencyString()
calculateTotalPay()
}
@@ -262,12 +264,11 @@ class FragmentAddItem : BaseFragment(R.layout.fragment_add_item),
StringBuilder().append(mDuration).append(" hours").toString()
mDuration!! * mPayRate
}
-
ShiftType.PIECE -> {
(mUnits ?: 0f) * mPayRate
}
}
- mTotalPayTextView.text = total.formatToTwoDpString()
+ mTotalPayTextView.text = total.formatAsCurrencyString()
}
}
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/ui/FragmentMain.kt b/app/src/main/java/com/appttude/h_mal/farmr/ui/FragmentMain.kt
index f083778..09592b9 100644
--- a/app/src/main/java/com/appttude/h_mal/farmr/ui/FragmentMain.kt
+++ b/app/src/main/java/com/appttude/h_mal/farmr/ui/FragmentMain.kt
@@ -70,7 +70,6 @@ class FragmentMain : BaseFragment(R.layout.fragment_main), BackPr
override fun onStart() {
super.onStart()
-
viewModel.refreshLiveData()
}
@@ -112,7 +111,7 @@ class FragmentMain : BaseFragment(R.layout.fragment_main), BackPr
}
R.id.clear_filter -> {
- viewModel.setFiltrationDetails(null, null, null, null)
+ viewModel.clearFilters()
return true
}
@@ -156,7 +155,7 @@ class FragmentMain : BaseFragment(R.layout.fragment_main), BackPr
.setSingleChoiceItems(
groupName,
checkedItem
- ) { p0, p1 -> sort = Sortable.valueOf(groupName[p1]) }
+ ) { p0, p1 -> sort = Sortable.getEnumByType(groupName[p1]) }
.setPositiveButton("Ascending") { dialog, id ->
viewModel.setSortAndOrder(sort)
dialog.dismiss()
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/ui/FurtherInfoFragment.kt b/app/src/main/java/com/appttude/h_mal/farmr/ui/FurtherInfoFragment.kt
index bda6dfd..e536e10 100644
--- a/app/src/main/java/com/appttude/h_mal/farmr/ui/FurtherInfoFragment.kt
+++ b/app/src/main/java/com/appttude/h_mal/farmr/ui/FurtherInfoFragment.kt
@@ -11,13 +11,14 @@ import com.appttude.h_mal.farmr.base.BaseFragment
import com.appttude.h_mal.farmr.data.legacydb.ShiftObject
import com.appttude.h_mal.farmr.model.ShiftType
import com.appttude.h_mal.farmr.utils.CURRENCY
-import com.appttude.h_mal.farmr.utils.ID
+import com.appttude.h_mal.farmr.utils.formatAsCurrencyString
+import com.appttude.h_mal.farmr.utils.formatToTwoDpString
import com.appttude.h_mal.farmr.utils.hide
import com.appttude.h_mal.farmr.utils.navigateToFragment
import com.appttude.h_mal.farmr.utils.show
-import com.appttude.h_mal.farmr.viewmodel.MainViewModel
+import com.appttude.h_mal.farmr.viewmodel.InfoViewModel
-class FurtherInfoFragment : BaseFragment(R.layout.fragment_futher_info) {
+class FurtherInfoFragment : BaseFragment(R.layout.fragment_futher_info) {
private lateinit var typeTV: TextView
private lateinit var descriptionTV: TextView
private lateinit var dateTV: TextView
@@ -52,60 +53,50 @@ class FurtherInfoFragment : BaseFragment(R.layout.fragment_futher
hourlyDetailHolder = view.findViewById(R.id.details_hourly_details)
unitsHolder = view.findViewById(R.id.details_units_holder)
- val id = arguments!!.getLong(ID)
-
editButton.setOnClickListener {
navigateToFragment(FragmentAddItem(), name = "additem", bundle = arguments!!)
}
- setupView(id)
+ viewModel.retrieveData(arguments)
}
- private fun setupView(id: Long) {
- viewModel.getCurrentShift(id)?.run {
- typeTV.text = type
- descriptionTV.text = description
- dateTV.text = date
- payRateTV.text = rateOfPay.toString()
- totalPayTV.text = StringBuilder(CURRENCY).append(totalPay).toString()
+ override fun onSuccess(data: Any?) {
+ super.onSuccess(data)
+ if (data is ShiftObject) data.setupView()
+ }
- when (ShiftType.getEnumByType(type)) {
- ShiftType.HOURLY -> {
- hourlyDetailHolder.show()
- unitsHolder.hide()
- times.text = StringBuilder(timeIn).append("-").append(timeOut).toString()
- breakTV.text = StringBuilder(breakMins).append("mins").toString()
- durationTV.text = buildDurationSummary(this)
- val paymentSummary =
- StringBuilder().append(duration).append(" Hours @ ").append(CURRENCY)
- .append(rateOfPay).append(" per Hour").append("\n")
- .append("Equals: ").append(CURRENCY).append(totalPay)
- totalPayTV.text = paymentSummary
- }
+ private fun ShiftObject.setupView() {
+ typeTV.text = type
+ descriptionTV.text = description
+ dateTV.text = date
+ payRateTV.text = rateOfPay.toString()
+ totalPayTV.text = StringBuilder(CURRENCY).append(totalPay).toString()
- ShiftType.PIECE -> {
- hourlyDetailHolder.hide()
- unitsHolder.show()
- unitsTV.text = units.toString()
+ when (ShiftType.getEnumByType(type)) {
+ ShiftType.HOURLY -> {
+ hourlyDetailHolder.show()
+ unitsHolder.hide()
+ times.text = StringBuilder(timeIn).append("-").append(timeOut).toString()
+ breakTV.text = StringBuilder().append(breakMins).append(" mins").toString()
+ durationTV.text = viewModel.buildDurationSummary(this)
+ val paymentSummary =
+ StringBuilder().append(duration).append(" Hours @ ")
+ .append(rateOfPay.formatAsCurrencyString()).append(" per Hour").append("\n")
+ .append("Equals: ").append(totalPay.formatAsCurrencyString())
+ totalPayTV.text = paymentSummary
+ }
- val paymentSummary =
- StringBuilder().append(units).append(" Units @ ").append(CURRENCY)
- .append(rateOfPay).append(" per Unit").append("\n")
- .append("Equals: ").append(CURRENCY).append(totalPay)
- totalPayTV.text = paymentSummary
- }
+ ShiftType.PIECE -> {
+ hourlyDetailHolder.hide()
+ unitsHolder.show()
+ unitsTV.text = units.toString()
+
+ val paymentSummary =
+ StringBuilder().append(units.formatAsCurrencyString()).append(" Units @ ")
+ .append(rateOfPay.formatAsCurrencyString()).append(" per Unit").append("\n")
+ .append("Equals: ").append(totalPay.formatAsCurrencyString())
+ totalPayTV.text = paymentSummary
}
}
}
-
- private fun buildDurationSummary(shiftObject: ShiftObject): String {
- val time = shiftObject.getHoursMinutesPairFromDuration()
-
- val stringBuilder = StringBuilder().append(time.first).append(" Hours ").append(time.second)
- .append(" Minutes ")
- if (shiftObject.breakMins > 0) {
- stringBuilder.append(" (+ ").append(shiftObject.breakMins).append(" minutes break)")
- }
- return stringBuilder.toString()
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/ui/MainActivity.kt b/app/src/main/java/com/appttude/h_mal/farmr/ui/MainActivity.kt
index 187d4cd..245b54f 100644
--- a/app/src/main/java/com/appttude/h_mal/farmr/ui/MainActivity.kt
+++ b/app/src/main/java/com/appttude/h_mal/farmr/ui/MainActivity.kt
@@ -19,7 +19,7 @@ import com.appttude.h_mal.farmr.utils.popBackStack
import com.appttude.h_mal.farmr.viewmodel.MainViewModel
import kotlin.system.exitProcess
-class MainActivity : BaseActivity() {
+class MainActivity : BaseActivity() {
private lateinit var toolbar: Toolbar
override fun onCreate(savedInstanceState: Bundle?) {
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/ui/SplashScreen.kt b/app/src/main/java/com/appttude/h_mal/farmr/ui/SplashScreen.kt
index 5310bdb..087320e 100644
--- a/app/src/main/java/com/appttude/h_mal/farmr/ui/SplashScreen.kt
+++ b/app/src/main/java/com/appttude/h_mal/farmr/ui/SplashScreen.kt
@@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.os.Handler
+import android.os.Looper
import android.view.View
import android.widget.RelativeLayout
import androidx.core.app.ActivityOptionsCompat
@@ -16,8 +17,7 @@ class SplashScreen : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash)
- val bundle = ActivityOptionsCompat.makeCustomAnimation(this, R.anim.hyperspace_jump, android.R.anim.fade_out).toBundle()
- val relativeLayout = findViewById(R.id.splash_layout) as RelativeLayout
+
val i = Intent(this@SplashScreen, MainActivity::class.java)
i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK)
Handler().postDelayed({
@@ -27,11 +27,11 @@ class SplashScreen : Activity() {
startActivity(i)
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
// finish();
- }, SPLASH_TIME_OUT.toLong())
+ }, SPLASH_TIME_OUT)
}
companion object {
// Splash screen timer
- private const val SPLASH_TIME_OUT = 2000
+ const val SPLASH_TIME_OUT: Long = 2000
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/utils/Formatting.kt b/app/src/main/java/com/appttude/h_mal/farmr/utils/Formatting.kt
index 00f9119..5b007e1 100644
--- a/app/src/main/java/com/appttude/h_mal/farmr/utils/Formatting.kt
+++ b/app/src/main/java/com/appttude/h_mal/farmr/utils/Formatting.kt
@@ -1,8 +1,10 @@
package com.appttude.h_mal.farmr.utils
import java.io.IOException
+import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.Calendar
+import java.util.Currency
import java.util.Date
import java.util.Locale
@@ -16,6 +18,14 @@ fun Float.formatToTwoDp(): Float {
return formattedString.toFloat()
}
+fun Float.formatAsCurrencyString(): String? {
+ val format: NumberFormat = NumberFormat.getCurrencyInstance()
+ format.maximumFractionDigits = 2
+ format.currency = Currency.getInstance("GBP")
+
+ return format.format(this)
+}
+
fun Float.formatToTwoDpString(): String {
return formatToTwoDp().toString()
}
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/ApplicationViewModelFactory.kt b/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/ApplicationViewModelFactory.kt
index 16883c7..73cc067 100644
--- a/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/ApplicationViewModelFactory.kt
+++ b/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/ApplicationViewModelFactory.kt
@@ -14,6 +14,9 @@ class ApplicationViewModelFactory(
with(modelClass) {
return when {
isAssignableFrom(MainViewModel::class.java) -> MainViewModel(repository)
+ isAssignableFrom(SubmissionViewModel::class.java) -> SubmissionViewModel(repository)
+ isAssignableFrom(InfoViewModel::class.java) -> InfoViewModel(repository)
+ isAssignableFrom(FilterViewModel::class.java) -> FilterViewModel(repository)
else -> throw IllegalArgumentException("Unknown ViewModel class")
} as T
}
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/FilterViewModel.kt b/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/FilterViewModel.kt
new file mode 100644
index 0000000..87bb2bd
--- /dev/null
+++ b/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/FilterViewModel.kt
@@ -0,0 +1,21 @@
+package com.appttude.h_mal.farmr.viewmodel
+
+import com.appttude.h_mal.farmr.data.Repository
+import com.appttude.h_mal.farmr.model.Success
+
+
+class FilterViewModel(
+ repository: Repository
+) : ShiftViewModel(repository) {
+
+ fun applyFilters(
+ description: String?,
+ dateFrom: String?,
+ dateTo: String?,
+ type: String?
+ ) {
+ super.setFiltrationDetails(description, dateFrom, dateTo, type)
+ onSuccess(Success("Filter(s) have been applied"))
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/InfoViewModel.kt b/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/InfoViewModel.kt
new file mode 100644
index 0000000..dea7585
--- /dev/null
+++ b/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/InfoViewModel.kt
@@ -0,0 +1,40 @@
+package com.appttude.h_mal.farmr.viewmodel
+
+import android.os.Bundle
+import com.appttude.h_mal.farmr.data.Repository
+import com.appttude.h_mal.farmr.data.legacydb.ShiftObject
+import com.appttude.h_mal.farmr.utils.ID
+
+
+class InfoViewModel(
+ repository: Repository
+) : ShiftViewModel(repository) {
+
+ fun retrieveData(bundle: Bundle?) {
+ val id = bundle?.getLong(ID)
+ if (id == null) {
+ onError("Failed to retrieve shift")
+ return
+ }
+
+ val shift = getCurrentShift(id)
+ if (shift == null) {
+ onError("Failed to retrieve shift")
+ return
+ }
+
+ onSuccess(shift)
+ }
+
+ fun buildDurationSummary(shiftObject: ShiftObject): String {
+ val time = shiftObject.getHoursMinutesPairFromDuration()
+
+ val stringBuilder = StringBuilder().append(time.first).append(" Hours ").append(time.second)
+ .append(" Minutes ")
+ if (shiftObject.breakMins > 0) {
+ stringBuilder.append(" (+ ").append(shiftObject.breakMins).append(" minutes break)")
+ }
+ return stringBuilder.toString()
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/MainViewModel.kt b/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/MainViewModel.kt
index bec425b..7a99874 100644
--- a/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/MainViewModel.kt
+++ b/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/MainViewModel.kt
@@ -1,13 +1,10 @@
package com.appttude.h_mal.farmr.viewmodel
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
-import android.os.Build
-import android.os.Environment
import androidx.annotation.RequiresPermission
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
-import com.appttude.h_mal.farmr.base.BaseViewModel
import com.appttude.h_mal.farmr.data.Repository
import com.appttude.h_mal.farmr.data.legacydb.ShiftObject
import com.appttude.h_mal.farmr.data.legacydb.ShiftsContract.ShiftsEntry.COLUMN_SHIFT_BREAK
@@ -21,24 +18,14 @@ import com.appttude.h_mal.farmr.data.legacydb.ShiftsContract.ShiftsEntry.COLUMN_
import com.appttude.h_mal.farmr.data.legacydb.ShiftsContract.ShiftsEntry.COLUMN_SHIFT_TYPE
import com.appttude.h_mal.farmr.data.legacydb.ShiftsContract.ShiftsEntry.COLUMN_SHIFT_UNIT
import com.appttude.h_mal.farmr.data.legacydb.ShiftsContract.ShiftsEntry._ID
-import com.appttude.h_mal.farmr.data.prefs.DESCRIPTION
-import com.appttude.h_mal.farmr.data.prefs.TIME_IN
-import com.appttude.h_mal.farmr.data.prefs.TIME_OUT
-import com.appttude.h_mal.farmr.data.prefs.TYPE
-import com.appttude.h_mal.farmr.model.FilterStore
import com.appttude.h_mal.farmr.model.Order
-import com.appttude.h_mal.farmr.model.Shift
import com.appttude.h_mal.farmr.model.ShiftType
import com.appttude.h_mal.farmr.model.Sortable
import com.appttude.h_mal.farmr.model.Success
import com.appttude.h_mal.farmr.utils.CURRENCY
-import com.appttude.h_mal.farmr.utils.calculateDuration
import com.appttude.h_mal.farmr.utils.convertDateString
-import com.appttude.h_mal.farmr.utils.dateStringIsValid
-import com.appttude.h_mal.farmr.utils.formatToTwoDp
-import com.appttude.h_mal.farmr.utils.getTimeString
+import com.appttude.h_mal.farmr.utils.formatAsCurrencyString
import com.appttude.h_mal.farmr.utils.sortedByOrder
-import com.appttude.h_mal.farmr.utils.timeStringIsValid
import jxl.Workbook
import jxl.WorkbookSettings
import jxl.write.Label
@@ -46,25 +33,24 @@ import jxl.write.WritableWorkbook
import jxl.write.WriteException
import java.io.File
import java.io.IOException
-import java.util.Calendar
import java.util.Locale
class MainViewModel(
private val repository: Repository
-) : BaseViewModel() {
+) : ShiftViewModel(repository) {
private val _shiftLiveData = MutableLiveData>()
- val shiftLiveData: LiveData> = _shiftLiveData
+ private val shiftLiveData: LiveData> = _shiftLiveData
private var mSort: Sortable = Sortable.ID
private var mOrder: Order = Order.ASCENDING
- private var mFilterStore: FilterStore? = null
-
private val observer = Observer> {
- val result = it.applyFilters().sortList(mSort, mOrder)
- onSuccess(result)
+ it?.let {
+ val result = it.applyFilters().sortList(mSort, mOrder)
+ onSuccess(result)
+ }
}
init {
@@ -148,8 +134,9 @@ class MainViewModel(
var countOfTypeP = 0
var totalUnits = 0f
var totalPay = 0f
- val lines = _shiftLiveData.value?.size ?: 0
- _shiftLiveData.value?.forEach {
+ var lines = 0
+ _shiftLiveData.value?.applyFilters()?.forEach {
+ lines += 1
totalDuration += it.duration
when (ShiftType.getEnumByType(it.type)) {
ShiftType.HOURLY -> countOfTypeH += 1
@@ -169,161 +156,6 @@ class MainViewModel(
)
}
- fun getCurrentShift(id: Long) = repository.readSingleShiftFromDatabase(id)
-
- fun insertHourlyShift(
- description: String,
- date: String,
- rateOfPay: Float,
- timeIn: String?,
- timeOut: String?,
- breakMins: Int?,
- ) {
- // Validate inputs from the edit texts
- (description.length > 3).validateField {
- onError("Description length should be longer")
- return
- }
- date.dateStringIsValid().validateField {
- onError("Date format is invalid")
- return
- }
- (rateOfPay >= 0.00).validateField {
- onError("Rate of pay is invalid")
- return
- }
- timeIn?.timeStringIsValid()?.validateField {
- onError("Time in format is in correct")
- return
- }
- timeOut?.timeStringIsValid()?.validateField {
- onError("Time out format is in correct")
- return
- }
- breakMins?.let { it > 0 }?.validateField {
- onError("Break in minutes is invalid")
- return
- }
-
- doTry {
- val result = insertShiftIntoDatabase(
- ShiftType.HOURLY,
- description,
- date,
- rateOfPay.formatToTwoDp(),
- timeIn,
- timeOut,
- breakMins,
- null
- )
-
- if (result) onSuccess(Success("Shift successfully added"))
- }
-
- }
-
- fun insertPieceRateShift(
- description: String,
- date: String,
- units: Float,
- rateOfPay: Float
- ) {
- // Validate inputs from the edit texts
- (description.length > 3).validateField {
- onError("Description length should be longer")
- return
- }
- date.dateStringIsValid().validateField {
- onError("Date format is invalid")
- return
- }
- (rateOfPay >= 0.00).validateField {
- onError("Rate of pay is invalid")
- return
- }
- (units.toInt() >= 0).validateField {
- onError("Units cannot be below zero")
- return
- }
-
- doTry {
- val result = insertShiftIntoDatabase(
- type = ShiftType.PIECE,
- description = description,
- date = date,
- rateOfPay = rateOfPay.formatToTwoDp(),
- null,
- null,
- null,
- units = units
- )
- if (result) onSuccess(Success("New shift successfully added"))
- }
- }
-
- fun updateShift(
- id: Long,
- type: String? = null,
- description: String? = null,
- date: String? = null,
- rateOfPay: Float? = null,
- timeIn: String? = null,
- timeOut: String? = null,
- breakMins: Int? = null,
- units: Float? = null,
- ) {
- description?.let {
- (it.length > 3).validateField {
- onError("Description length should be longer")
- return
- }
- }
- date?.dateStringIsValid()?.validateField {
- onError("Date format is invalid")
- return
- }
- rateOfPay?.let {
- (it >= 0.00).validateField {
- onError("Rate of pay is invalid")
- return
- }
- }
- units?.let {
- (it.toInt() >= 0).validateField {
- onError("Units cannot be below zero")
- return
- }
- }
- timeIn?.timeStringIsValid()?.validateField {
- onError("Time in format is in correct")
- return
- }
- timeOut?.timeStringIsValid()?.validateField {
- onError("Time out format is in correct")
- return
- }
- breakMins?.let { it >= 0 }?.validateField {
- onError("Break in minutes is invalid")
- return
- }
-
- doTry {
- val result = updateShiftInDatabase(
- id,
- type = type?.let { ShiftType.getEnumByType(it) },
- description = description,
- date = date,
- rateOfPay = rateOfPay,
- timeIn = timeIn,
- timeOut = timeOut,
- breakMins = breakMins,
- units = units
- )
-
- if (result) onSuccess(Success("Shift successfully updated"))
- }
- }
-
fun deleteShift(id: Long) {
if (!repository.deleteSingleShiftFromDatabase(id)) {
onError("Failed to delete shift")
@@ -340,134 +172,6 @@ class MainViewModel(
}
}
- private fun updateShiftInDatabase(
- id: Long,
- type: ShiftType? = null,
- description: String? = null,
- date: String? = null,
- rateOfPay: Float? = null,
- timeIn: String? = null,
- timeOut: String? = null,
- breakMins: Int? = null,
- units: Float? = null,
- ): Boolean {
- val currentShift = repository.readSingleShiftFromDatabase(id)?.copyToShift()
- ?: throw IOException("Cannot update shift as it does not exist")
-
- val shift = when (type) {
- ShiftType.HOURLY -> {
- // Shift type has changed so mandatory fields for hourly shift are now required as well
- val insertTimeIn =
- (timeIn ?: currentShift.timeIn) ?: throw IOException("No time in inserted")
- val insertTimeOut =
- (timeOut ?: currentShift.timeOut) ?: throw IOException("No time out inserted")
- Shift(
- description = description ?: currentShift.description,
- date = date ?: currentShift.date,
- timeIn = insertTimeIn,
- timeOut = insertTimeOut,
- breakMins = breakMins ?: currentShift.breakMins,
- rateOfPay = rateOfPay ?: currentShift.rateOfPay
- )
- }
-
- ShiftType.PIECE -> {
- // Shift type has changed so mandatory fields for piece rate shift are now required as well
- val insertUnits = (units ?: currentShift.units)
- ?: throw IOException("Units must be inserted for piece rate shifts")
- Shift(
- description = description ?: currentShift.description,
- date = date ?: currentShift.date,
- units = insertUnits,
- rateOfPay = rateOfPay ?: currentShift.rateOfPay
- )
- }
-
- else -> {
- if (timeIn == null && timeOut == null && units == null && breakMins == null && rateOfPay == null) {
- // Updates to description or date field
- currentShift.copy(
- description = description ?: currentShift.description,
- date = date ?: currentShift.date,
- )
- } else {
- // Updating shifts where shift type has remained the same
- when (currentShift.type) {
- ShiftType.HOURLY -> {
- val insertTimeIn = (timeIn ?: currentShift.timeIn) ?: throw IOException(
- "No time in inserted"
- )
- val insertTimeOut = (timeOut ?: currentShift.timeOut)
- ?: throw IOException("No time out inserted")
- Shift(
- description = description ?: currentShift.description,
- date = date ?: currentShift.date,
- timeIn = insertTimeIn,
- timeOut = insertTimeOut,
- breakMins = breakMins ?: currentShift.breakMins,
- rateOfPay = rateOfPay ?: currentShift.rateOfPay
- )
- }
-
- ShiftType.PIECE -> {
- val insertUnits = (units ?: currentShift.units)
- ?: throw IOException("Units must be inserted for piece rate shifts")
- Shift(
- description = description ?: currentShift.description,
- date = date ?: currentShift.date,
- units = insertUnits,
- rateOfPay = rateOfPay ?: currentShift.rateOfPay
- )
- }
- }
- }
- }
- }
-
- return repository.updateShiftIntoDatabase(id, shift)
- }
-
- private fun insertShiftIntoDatabase(
- type: ShiftType,
- description: String,
- date: String,
- rateOfPay: Float,
- timeIn: String?,
- timeOut: String?,
- breakMins: Int?,
- units: Float?,
- ): Boolean {
- val shift = when (type) {
- ShiftType.HOURLY -> {
- if (timeIn.isNullOrBlank() && timeOut.isNullOrBlank()) throw IOException("Time in and time out are null")
- val calendar by lazy { Calendar.getInstance() }
- val insertTimeIn = timeIn ?: calendar.getTimeString()
- val insertTimeOut = timeOut ?: calendar.getTimeString()
-
- Shift(
- description = description,
- date = date,
- timeIn = insertTimeIn,
- timeOut = insertTimeOut,
- breakMins = breakMins,
- rateOfPay = rateOfPay
- )
- }
-
- ShiftType.PIECE -> {
- Shift(
- description = description,
- date = date,
- units = units!!,
- rateOfPay = rateOfPay,
- )
- }
- }
-
- return repository.insertShiftIntoDatabase(shift)
- }
-
-
private fun buildInfoString(
totalDuration: Float,
countOfHourly: Int,
@@ -488,63 +192,21 @@ class MainViewModel(
stringBuilder.append("Total Units: ").append(totalUnits).append("\n")
}
if (totalPay != 0f) {
- stringBuilder.append("Total Pay: ").append(CURRENCY).append(totalPay).append("\n")
+ stringBuilder.append("Total Pay: ").append(totalPay.formatAsCurrencyString())
}
return stringBuilder.toString()
}
fun refreshLiveData() {
- _shiftLiveData.postValue(repository.readShiftsFromDatabase())
+ repository.readShiftsFromDatabase()?.let { _shiftLiveData.postValue(it) }
}
- private inline fun Boolean.validateField(failureCallback: () -> Unit) {
- if (!this) failureCallback.invoke()
- }
-
- /**
- * Lambda function that will invoke onError(...) on failure
- * but update live data when successful
- */
- private inline fun doTry(operation: () -> Unit) {
- try {
- operation.invoke()
- refreshLiveData()
- } catch (e: Exception) {
- onError(e)
- }
- }
-
- fun setFiltrationDetails(
- description: String?,
- dateFrom: String?,
- dateTo: String?,
- type: String?
- ) {
- repository.setFilteringDetailsInPrefs(description, dateFrom, dateTo, type)
- onSuccess(Success("Filter(s) successfully applied"))
+ fun clearFilters() {
+ super.setFiltrationDetails(null, null, null, null)
+ onSuccess(Success("Filters have been cleared"))
refreshLiveData()
}
- fun getFiltrationDetails(): FilterStore {
- val prefs = repository.retrieveFilteringDetailsInPrefs()
- mFilterStore = FilterStore(
- prefs[DESCRIPTION],
- prefs[TIME_IN],
- prefs[TIME_OUT],
- prefs[TYPE]
- )
- return mFilterStore!!
- }
-
- fun retrieveDurationText(mTimeIn: String?, mTimeOut: String?, mBreaks: Int?): Float? {
- try {
- return calculateDuration(mTimeIn, mTimeOut, mBreaks)
- } catch (e: IOException) {
- onError(e)
- }
- return null
- }
-
@RequiresPermission(WRITE_EXTERNAL_STORAGE)
fun createExcelSheet(file: File): File? {
val wbSettings = WorkbookSettings().apply {
@@ -574,7 +236,8 @@ class MainViewModel(
return null
}
val sortAndOrder = getSortAndOrder()
- val data = shiftLiveData.value!!.applyFilters().sortList(sortAndOrder.first, sortAndOrder.second)
+ val data = shiftLiveData.value!!.applyFilters()
+ .sortList(sortAndOrder.first, sortAndOrder.second)
var currentRow = 0
val cells = data.mapIndexed { index, shift ->
currentRow += 1
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/ShiftViewModel.kt b/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/ShiftViewModel.kt
new file mode 100644
index 0000000..33c7ea5
--- /dev/null
+++ b/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/ShiftViewModel.kt
@@ -0,0 +1,52 @@
+package com.appttude.h_mal.farmr.viewmodel
+
+import com.appttude.h_mal.farmr.base.BaseViewModel
+import com.appttude.h_mal.farmr.data.Repository
+import com.appttude.h_mal.farmr.data.prefs.DESCRIPTION
+import com.appttude.h_mal.farmr.data.prefs.DATE_IN
+import com.appttude.h_mal.farmr.data.prefs.DATE_OUT
+import com.appttude.h_mal.farmr.data.prefs.TYPE
+import com.appttude.h_mal.farmr.model.FilterStore
+
+
+open class ShiftViewModel(
+ private val repository: Repository
+) : BaseViewModel() {
+
+ /*
+ * Add Item & Further info
+ */
+ fun getCurrentShift(id: Long) = repository.readSingleShiftFromDatabase(id)
+
+ /**
+ * Lambda function that will invoke onError(...) on failure
+ * but update live data when successful
+ */
+ private inline fun doTry(operation: () -> Unit) {
+ try {
+ operation.invoke()
+ } catch (e: Exception) {
+ onError(e)
+ }
+ }
+
+ open fun setFiltrationDetails(
+ description: String?,
+ dateFrom: String?,
+ dateTo: String?,
+ type: String?
+ ) {
+ repository.setFilteringDetailsInPrefs(description, dateFrom, dateTo, type)
+ }
+
+ open fun getFiltrationDetails(): FilterStore {
+ val prefs = repository.retrieveFilteringDetailsInPrefs()
+ return FilterStore(
+ prefs[DESCRIPTION],
+ prefs[DATE_IN],
+ prefs[DATE_OUT],
+ prefs[TYPE]
+ )
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/SubmissionViewModel.kt b/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/SubmissionViewModel.kt
new file mode 100644
index 0000000..322ca40
--- /dev/null
+++ b/app/src/main/java/com/appttude/h_mal/farmr/viewmodel/SubmissionViewModel.kt
@@ -0,0 +1,308 @@
+package com.appttude.h_mal.farmr.viewmodel
+
+import com.appttude.h_mal.farmr.data.Repository
+import com.appttude.h_mal.farmr.model.Shift
+import com.appttude.h_mal.farmr.model.ShiftType
+import com.appttude.h_mal.farmr.model.Success
+import com.appttude.h_mal.farmr.utils.calculateDuration
+import com.appttude.h_mal.farmr.utils.dateStringIsValid
+import com.appttude.h_mal.farmr.utils.formatToTwoDp
+import com.appttude.h_mal.farmr.utils.getTimeString
+import com.appttude.h_mal.farmr.utils.timeStringIsValid
+import java.io.IOException
+import java.util.Calendar
+
+
+class SubmissionViewModel(
+ private val repository: Repository
+) : ShiftViewModel(repository) {
+
+ fun insertHourlyShift(
+ description: String,
+ date: String,
+ rateOfPay: Float,
+ timeIn: String?,
+ timeOut: String?,
+ breakMins: Int?,
+ ) {
+ // Validate inputs from the edit texts
+ (description.length > 3).validateField {
+ onError("Description length should be longer")
+ return
+ }
+ date.dateStringIsValid().validateField {
+ onError("Date format is invalid")
+ return
+ }
+ (rateOfPay >= 0.00).validateField {
+ onError("Rate of pay is invalid")
+ return
+ }
+ timeIn?.timeStringIsValid()?.validateField {
+ onError("Time in format is in correct")
+ return
+ }
+ timeOut?.timeStringIsValid()?.validateField {
+ onError("Time out format is in correct")
+ return
+ }
+ breakMins?.let { it >= 0 }?.validateField {
+ onError("Break in minutes is invalid")
+ return
+ }
+
+ val result = insertShiftIntoDatabase(
+ ShiftType.HOURLY,
+ description,
+ date,
+ rateOfPay.formatToTwoDp(),
+ timeIn,
+ timeOut,
+ breakMins,
+ null
+ )
+
+ if (result) onSuccess(Success("New shift successfully added"))
+ else onError("Cannot insert shift")
+ }
+
+ fun insertPieceRateShift(
+ description: String,
+ date: String,
+ units: Float,
+ rateOfPay: Float
+ ) {
+ // Validate inputs from the edit texts
+ (description.length > 3).validateField {
+ onError("Description length should be longer")
+ return
+ }
+ date.dateStringIsValid().validateField {
+ onError("Date format is invalid")
+ return
+ }
+ (rateOfPay >= 0.00).validateField {
+ onError("Rate of pay is invalid")
+ return
+ }
+ (units.toInt() >= 0).validateField {
+ onError("Units cannot be below zero")
+ return
+ }
+
+ val result = insertShiftIntoDatabase(
+ type = ShiftType.PIECE,
+ description = description,
+ date = date,
+ rateOfPay = rateOfPay.formatToTwoDp(),
+ null,
+ null,
+ null,
+ units = units
+ )
+ if (result) onSuccess(Success("New shift successfully added"))
+ else onError("Cannot insert shift")
+ }
+
+ fun updateShift(
+ id: Long,
+ type: String? = null,
+ description: String? = null,
+ date: String? = null,
+ rateOfPay: Float? = null,
+ timeIn: String? = null,
+ timeOut: String? = null,
+ breakMins: Int? = null,
+ units: Float? = null,
+ ) {
+ description?.let {
+ (it.length > 3).validateField {
+ onError("Description length should be longer")
+ return
+ }
+ }
+ date?.dateStringIsValid()?.validateField {
+ onError("Date format is invalid")
+ return
+ }
+ rateOfPay?.let {
+ (it >= 0.00).validateField {
+ onError("Rate of pay is invalid")
+ return
+ }
+ }
+ units?.let {
+ (it.toInt() >= 0).validateField {
+ onError("Units cannot be below zero")
+ return
+ }
+ }
+ timeIn?.timeStringIsValid()?.validateField {
+ onError("Time in format is in correct")
+ return
+ }
+ timeOut?.timeStringIsValid()?.validateField {
+ onError("Time out format is in correct")
+ return
+ }
+ breakMins?.let { it >= 0 }?.validateField {
+ onError("Break in minutes is invalid")
+ return
+ }
+
+ val result = updateShiftInDatabase(
+ id,
+ type = type?.let { ShiftType.getEnumByType(it) },
+ description = description,
+ date = date,
+ rateOfPay = rateOfPay,
+ timeIn = timeIn,
+ timeOut = timeOut,
+ breakMins = breakMins,
+ units = units
+ )
+
+ if (result) onSuccess(Success("Shift successfully updated"))
+ else onError("Cannot update shift")
+ }
+
+ private fun updateShiftInDatabase(
+ id: Long,
+ type: ShiftType? = null,
+ description: String? = null,
+ date: String? = null,
+ rateOfPay: Float? = null,
+ timeIn: String? = null,
+ timeOut: String? = null,
+ breakMins: Int? = null,
+ units: Float? = null,
+ ): Boolean {
+ val currentShift = repository.readSingleShiftFromDatabase(id)?.copyToShift()
+ ?: throw IOException("Cannot update shift as it does not exist")
+
+ val shift = when (type) {
+ ShiftType.HOURLY -> {
+ // Shift type has changed so mandatory fields for hourly shift are now required as well
+ val insertTimeIn =
+ (timeIn ?: currentShift.timeIn) ?: throw IOException("No time in inserted")
+ val insertTimeOut =
+ (timeOut ?: currentShift.timeOut) ?: throw IOException("No time out inserted")
+ Shift(
+ description = description ?: currentShift.description,
+ date = date ?: currentShift.date,
+ timeIn = insertTimeIn,
+ timeOut = insertTimeOut,
+ breakMins = breakMins ?: currentShift.breakMins,
+ rateOfPay = rateOfPay ?: currentShift.rateOfPay
+ )
+ }
+
+ ShiftType.PIECE -> {
+ // Shift type has changed so mandatory fields for piece rate shift are now required as well
+ val insertUnits = (units ?: currentShift.units)
+ ?: throw IOException("Units must be inserted for piece rate shifts")
+ Shift(
+ description = description ?: currentShift.description,
+ date = date ?: currentShift.date,
+ units = insertUnits,
+ rateOfPay = rateOfPay ?: currentShift.rateOfPay
+ )
+ }
+
+ else -> {
+ if (timeIn == null && timeOut == null && units == null && breakMins == null && rateOfPay == null) {
+ // Updates to description or date field
+ currentShift.copy(
+ description = description ?: currentShift.description,
+ date = date ?: currentShift.date,
+ )
+ } else {
+ // Updating shifts where shift type has remained the same
+ when (currentShift.type) {
+ ShiftType.HOURLY -> {
+ val insertTimeIn = (timeIn ?: currentShift.timeIn) ?: throw IOException(
+ "No time in inserted"
+ )
+ val insertTimeOut = (timeOut ?: currentShift.timeOut)
+ ?: throw IOException("No time out inserted")
+ Shift(
+ description = description ?: currentShift.description,
+ date = date ?: currentShift.date,
+ timeIn = insertTimeIn,
+ timeOut = insertTimeOut,
+ breakMins = breakMins ?: currentShift.breakMins,
+ rateOfPay = rateOfPay ?: currentShift.rateOfPay
+ )
+ }
+
+ ShiftType.PIECE -> {
+ val insertUnits = (units ?: currentShift.units)
+ ?: throw IOException("Units must be inserted for piece rate shifts")
+ Shift(
+ description = description ?: currentShift.description,
+ date = date ?: currentShift.date,
+ units = insertUnits,
+ rateOfPay = rateOfPay ?: currentShift.rateOfPay
+ )
+ }
+ }
+ }
+ }
+ }
+
+ return repository.updateShiftIntoDatabase(id, shift)
+ }
+
+ private fun insertShiftIntoDatabase(
+ type: ShiftType,
+ description: String,
+ date: String,
+ rateOfPay: Float,
+ timeIn: String?,
+ timeOut: String?,
+ breakMins: Int?,
+ units: Float?,
+ ): Boolean {
+ val shift = when (type) {
+ ShiftType.HOURLY -> {
+ if (timeIn.isNullOrBlank() && timeOut.isNullOrBlank()) throw IOException("Time in and time out are null")
+ val calendar by lazy { Calendar.getInstance() }
+ val insertTimeIn = timeIn ?: calendar.getTimeString()
+ val insertTimeOut = timeOut ?: calendar.getTimeString()
+ Shift(
+ description = description,
+ date = date,
+ timeIn = insertTimeIn,
+ timeOut = insertTimeOut,
+ breakMins = breakMins,
+ rateOfPay = rateOfPay
+ )
+ }
+
+ ShiftType.PIECE -> {
+ Shift(
+ description = description,
+ date = date,
+ units = units!!,
+ rateOfPay = rateOfPay,
+ )
+ }
+ }
+
+ return repository.insertShiftIntoDatabase(shift)
+ }
+
+ private inline fun Boolean.validateField(failureCallback: () -> Unit) {
+ if (!this) failureCallback.invoke()
+ }
+
+ fun retrieveDurationText(mTimeIn: String?, mTimeOut: String?, mBreaks: Int?): Float? {
+ try {
+ return calculateDuration(mTimeIn, mTimeOut, mBreaks)
+ } catch (e: IOException) {
+ onError(e)
+ }
+ return null
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/appttude/h_mal/farmr/ExampleUnitTest.java b/app/src/test/java/com/appttude/h_mal/farmr/ExampleUnitTest.java
deleted file mode 100644
index cfb3bfd..0000000
--- a/app/src/test/java/com/appttude/h_mal/farmr/ExampleUnitTest.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.appttude.h_mal.farmr;
-
-import org.junit.Test;
-
-import static org.junit.Assert.assertEquals;
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * @see Testing documentation
- */
-public class ExampleUnitTest {
- @Test
- public void addition_isCorrect() throws Exception {
- assertEquals(4, 2 + 2);
- }
-}
\ No newline at end of file
diff --git a/app/src/test/java/com/appttude/h_mal/farmr/data/RepositoryImplTest.kt b/app/src/test/java/com/appttude/h_mal/farmr/data/RepositoryImplTest.kt
new file mode 100644
index 0000000..4c53925
--- /dev/null
+++ b/app/src/test/java/com/appttude/h_mal/farmr/data/RepositoryImplTest.kt
@@ -0,0 +1,52 @@
+package com.appttude.h_mal.farmr.data
+
+import com.appttude.h_mal.farmr.data.legacydb.LegacyDatabase
+import com.appttude.h_mal.farmr.data.legacydb.ShiftObject
+import com.appttude.h_mal.farmr.data.prefs.PreferenceProvider
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import org.junit.Before
+import org.junit.Test
+import org.mockito.ArgumentMatchers.anyLong
+import java.util.UUID
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+
+class RepositoryImplTest {
+
+ private lateinit var repository: RepositoryImpl
+
+ @MockK
+ lateinit var db: LegacyDatabase
+
+ @MockK
+ lateinit var prefs: PreferenceProvider
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ repository = RepositoryImpl(db, prefs)
+ }
+
+ @Test
+ fun readDatabase_validResponse() {
+ // Arrange
+ val elements = listOf(
+ mockk { every { id } returns anyLong() },
+ mockk { every { id } returns anyLong() },
+ mockk { every { id } returns anyLong() },
+ mockk { every { id } returns anyLong() }
+ )
+
+ //Act
+ every { db.readShiftsFromDatabase() } returns elements
+
+ // Assert
+ val result = repository.readShiftsFromDatabase()
+ assertIs>(result)
+ assertEquals(result.first().id, anyLong())
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/appttude/h_mal/farmr/utils/testUtils.kt b/app/src/test/java/com/appttude/h_mal/farmr/utils/testUtils.kt
new file mode 100644
index 0000000..4d64b8d
--- /dev/null
+++ b/app/src/test/java/com/appttude/h_mal/farmr/utils/testUtils.kt
@@ -0,0 +1,39 @@
+package com.appttude.h_mal.farmr.utils
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Observer
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+
+fun LiveData.getOrAwaitValue(
+ time: Long = 2,
+ timeUnit: TimeUnit = TimeUnit.SECONDS
+): T {
+ var data: T? = null
+ val latch = CountDownLatch(1)
+ val observer = object : Observer {
+ override fun onChanged(o: T?) {
+ data = o
+ latch.countDown()
+ this@getOrAwaitValue.removeObserver(this)
+ }
+ }
+
+ this.observeForever(observer)
+
+ // Don't wait indefinitely if the LiveData is not set.
+ if (!latch.await(time, timeUnit)) {
+ throw TimeoutException("LiveData value was never set.")
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ return data as T
+}
+
+fun sleep(millis: Long = 1000) {
+ runBlocking(Dispatchers.Default) { delay(millis) }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/appttude/h_mal/farmr/viewmodel/MainViewModelTest.kt b/app/src/test/java/com/appttude/h_mal/farmr/viewmodel/MainViewModelTest.kt
new file mode 100644
index 0000000..127dab4
--- /dev/null
+++ b/app/src/test/java/com/appttude/h_mal/farmr/viewmodel/MainViewModelTest.kt
@@ -0,0 +1,239 @@
+package com.appttude.h_mal.farmr.viewmodel
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import com.appttude.h_mal.farmr.data.Repository
+import com.appttude.h_mal.farmr.data.legacydb.ShiftObject
+import com.appttude.h_mal.farmr.data.prefs.DATE_IN
+import com.appttude.h_mal.farmr.data.prefs.DATE_OUT
+import com.appttude.h_mal.farmr.data.prefs.DESCRIPTION
+import com.appttude.h_mal.farmr.data.prefs.TYPE
+import com.appttude.h_mal.farmr.model.ShiftType
+import com.appttude.h_mal.farmr.model.ViewState
+import com.appttude.h_mal.farmr.utils.getOrAwaitValue
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.ArgumentMatchers.anyFloat
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.ArgumentMatchers.anyList
+import org.mockito.ArgumentMatchers.anyLong
+import org.mockito.ArgumentMatchers.anyString
+import java.util.concurrent.TimeoutException
+import kotlin.test.assertEquals
+
+class MainViewModelTest {
+ @get:Rule
+ val rule = InstantTaskExecutorRule()
+
+ private lateinit var repository: Repository
+ private lateinit var viewModel: MainViewModel
+
+ @Before
+ fun setUp() {
+ repository = mockk()
+ every { repository.readShiftsFromDatabase() }.returns(null)
+ every { repository.retrieveFilteringDetailsInPrefs() }.returns(getFilter())
+ viewModel = MainViewModel(repository)
+ }
+
+ @Test
+ fun initViewModel_liveDataIsEmpty() {
+ // Assert
+ assertThrows(TimeoutException::class.java) { viewModel.uiState.getOrAwaitValue() }
+ }
+
+ @Test
+ fun getShiftsFromRepository_liveDataIsShown() {
+ // Arrange
+ val listOfShifts = anyList()
+
+ // Act
+ every { repository.readShiftsFromDatabase() }.returns(listOfShifts)
+ viewModel.refreshLiveData()
+
+ // Assert
+ assertEquals(retrieveCurrentData(), listOfShifts)
+ }
+
+ @Test
+ fun getShiftsFromRepository_liveDataIsShown_defaultFiltersAndSortsValid() {
+ // Arrange
+ val listOfShifts = getShifts()
+
+ // Act
+ every { repository.readShiftsFromDatabase() }.returns(listOfShifts)
+ viewModel.refreshLiveData()
+ val retrievedShifts = retrieveCurrentData()
+ val description = viewModel.getInformation()
+
+ // Assert
+ assertEquals(retrievedShifts, listOfShifts)
+ assertEquals(
+ description, "8 Shifts\n" +
+ " (4 Hourly/4 Piece Rate)\n" +
+ "Total Hours: 4.0\n" +
+ "Total Units: 4.0\n" +
+ "Total Pay: £70.00"
+ )
+ }
+
+ @Test
+ fun getShiftsFromRepository_applyFiltersThenClearFilters_descriptionIsValid() {
+ // Arrange
+ val listOfShifts = getShifts()
+ val filteredShifts = getShifts().filter { it.type == ShiftType.HOURLY.type }
+
+ // Act
+ every { repository.readShiftsFromDatabase() }.returns(listOfShifts)
+ every { repository.retrieveFilteringDetailsInPrefs() }.returns(getFilter(type = ShiftType.HOURLY.type))
+ viewModel.refreshLiveData()
+ val retrievedShifts = retrieveCurrentData()
+ val description = viewModel.getInformation()
+
+ every { repository.setFilteringDetailsInPrefs(null, null, null, null) }.returns(Unit)
+ every { repository.retrieveFilteringDetailsInPrefs() }.returns(getFilter())
+ viewModel.clearFilters()
+ val descriptionAfterClearedFilter = viewModel.getInformation()
+
+ // Assert
+ assertEquals(retrievedShifts, filteredShifts)
+ assertEquals(
+ description, "4 Shifts\n" +
+ "Total Hours: 4.0\n" +
+ "Total Pay: £30.00"
+ )
+ assertEquals(
+ descriptionAfterClearedFilter, "8 Shifts\n" +
+ " (4 Hourly/4 Piece Rate)\n" +
+ "Total Hours: 4.0\n" +
+ "Total Units: 4.0\n" +
+ "Total Pay: £70.00"
+ )
+ }
+
+ private fun retrieveCurrentData() =
+ (viewModel.uiState.getOrAwaitValue() as ViewState.HasData<*>).data
+
+ private fun getFilter(
+ description: String? = null,
+ type: String? = null,
+ dateIn: String? = null,
+ dateOut: String? = null
+ ): Map =
+ mapOf(
+ Pair(DESCRIPTION, description),
+ Pair(DATE_IN, dateIn),
+ Pair(DATE_OUT, dateOut),
+ Pair(TYPE, type)
+ )
+
+ private fun getShifts() = listOf(
+ ShiftObject(
+ anyLong(),
+ ShiftType.HOURLY.type,
+ "Day one",
+ "2023-08-01",
+ "12:00",
+ "13:00",
+ 1f,
+ anyInt(),
+ anyFloat(),
+ 10f,
+ 10f
+ ),
+ ShiftObject(
+ anyLong(),
+ ShiftType.HOURLY.type,
+ "Day two",
+ "2023-08-02",
+ "12:00",
+ "13:00",
+ 1f,
+ anyInt(),
+ anyFloat(),
+ 10f,
+ 10f
+ ),
+ ShiftObject(
+ anyLong(),
+ ShiftType.HOURLY.type,
+ "Day three",
+ "2023-08-03",
+ "12:00",
+ "13:00",
+ 1f,
+ 30,
+ anyFloat(),
+ 10f,
+ 5f
+ ),
+ ShiftObject(
+ anyLong(),
+ ShiftType.HOURLY.type,
+ "Day four",
+ "2023-08-04",
+ "12:00",
+ "13:00",
+ 1f,
+ 30,
+ anyFloat(),
+ 10f,
+ 5f
+ ),
+ ShiftObject(
+ anyLong(),
+ ShiftType.PIECE.type,
+ "Day five",
+ "2023-08-05",
+ anyString(),
+ anyString(),
+ anyFloat(),
+ anyInt(),
+ 1f,
+ 10f,
+ 10f
+ ),
+ ShiftObject(
+ anyLong(),
+ ShiftType.PIECE.type,
+ "Day six",
+ "2023-08-06",
+ anyString(),
+ anyString(),
+ anyFloat(),
+ anyInt(),
+ 1f,
+ 10f,
+ 10f
+ ),
+ ShiftObject(
+ anyLong(),
+ ShiftType.PIECE.type,
+ "Day seven",
+ "2023-08-07",
+ anyString(),
+ anyString(),
+ anyFloat(),
+ anyInt(),
+ 1f,
+ 10f,
+ 10f
+ ),
+ ShiftObject(
+ anyLong(),
+ ShiftType.PIECE.type,
+ "Day eight",
+ "2023-08-08",
+ anyString(),
+ anyString(),
+ anyFloat(),
+ anyInt(),
+ 1f,
+ 10f,
+ 10f
+ ),
+ )
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/appttude/h_mal/farmr/viewmodel/ShiftViewModelTest.kt b/app/src/test/java/com/appttude/h_mal/farmr/viewmodel/ShiftViewModelTest.kt
new file mode 100644
index 0000000..1106249
--- /dev/null
+++ b/app/src/test/java/com/appttude/h_mal/farmr/viewmodel/ShiftViewModelTest.kt
@@ -0,0 +1,171 @@
+package com.appttude.h_mal.farmr.viewmodel
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import com.appttude.h_mal.farmr.data.Repository
+import com.appttude.h_mal.farmr.data.legacydb.ShiftObject
+import com.appttude.h_mal.farmr.model.ShiftType
+import com.appttude.h_mal.farmr.model.ViewState
+import com.appttude.h_mal.farmr.utils.getOrAwaitValue
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.InjectMockKs
+import io.mockk.impl.annotations.RelaxedMockK
+import org.junit.Before
+import org.junit.Rule
+import org.mockito.ArgumentMatchers
+
+open class ShiftViewModelTest {
+ @get:Rule
+ val rule = InstantTaskExecutorRule()
+
+ @RelaxedMockK
+ lateinit var repository: Repository
+
+ @InjectMockKs
+ lateinit var viewModel: V
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this)
+ }
+
+ fun retrieveCurrentData() =
+ (viewModel.uiState.getOrAwaitValue() as ViewState.HasData<*>).data
+
+ fun retrieveCurrentError() =
+ (viewModel.uiState.getOrAwaitValue() as ViewState.HasError<*>).error
+
+ fun getHourlyShift() = ShiftObject(
+ ArgumentMatchers.anyLong(),
+ ShiftType.HOURLY.type,
+ "Day one",
+ "2023-08-01",
+ "12:00",
+ "13:00",
+ 1f,
+ ArgumentMatchers.anyInt(),
+ ArgumentMatchers.anyFloat(),
+ 10f,
+ 10f
+ )
+
+ fun getPieceRateShift() = ShiftObject(
+ ArgumentMatchers.anyLong(),
+ ShiftType.PIECE.type,
+ "Day five",
+ "2023-08-05",
+ ArgumentMatchers.anyString(),
+ ArgumentMatchers.anyString(),
+ ArgumentMatchers.anyFloat(),
+ ArgumentMatchers.anyInt(),
+ 1f,
+ 10f,
+ 10f
+ )
+
+ fun getShifts() = listOf(
+ ShiftObject(
+ ArgumentMatchers.anyLong(),
+ ShiftType.HOURLY.type,
+ "Day one",
+ "2023-08-01",
+ "12:00",
+ "13:00",
+ 1f,
+ ArgumentMatchers.anyInt(),
+ ArgumentMatchers.anyFloat(),
+ 10f,
+ 10f
+ ),
+ ShiftObject(
+ ArgumentMatchers.anyLong(),
+ ShiftType.HOURLY.type,
+ "Day two",
+ "2023-08-02",
+ "12:00",
+ "13:00",
+ 1f,
+ ArgumentMatchers.anyInt(),
+ ArgumentMatchers.anyFloat(),
+ 10f,
+ 10f
+ ),
+ ShiftObject(
+ ArgumentMatchers.anyLong(),
+ ShiftType.HOURLY.type,
+ "Day three",
+ "2023-08-03",
+ "12:00",
+ "13:00",
+ 1f,
+ 30,
+ ArgumentMatchers.anyFloat(),
+ 10f,
+ 5f
+ ),
+ ShiftObject(
+ ArgumentMatchers.anyLong(),
+ ShiftType.HOURLY.type,
+ "Day four",
+ "2023-08-04",
+ "12:00",
+ "13:00",
+ 1f,
+ 30,
+ ArgumentMatchers.anyFloat(),
+ 10f,
+ 5f
+ ),
+ ShiftObject(
+ ArgumentMatchers.anyLong(),
+ ShiftType.PIECE.type,
+ "Day five",
+ "2023-08-05",
+ ArgumentMatchers.anyString(),
+ ArgumentMatchers.anyString(),
+ ArgumentMatchers.anyFloat(),
+ ArgumentMatchers.anyInt(),
+ 1f,
+ 10f,
+ 10f
+ ),
+ ShiftObject(
+ ArgumentMatchers.anyLong(),
+ ShiftType.PIECE.type,
+ "Day six",
+ "2023-08-06",
+ ArgumentMatchers.anyString(),
+ ArgumentMatchers.anyString(),
+ ArgumentMatchers.anyFloat(),
+ ArgumentMatchers.anyInt(),
+ 1f,
+ 10f,
+ 10f
+ ),
+ ShiftObject(
+ ArgumentMatchers.anyLong(),
+ ShiftType.PIECE.type,
+ "Day seven",
+ "2023-08-07",
+ ArgumentMatchers.anyString(),
+ ArgumentMatchers.anyString(),
+ ArgumentMatchers.anyFloat(),
+ ArgumentMatchers.anyInt(),
+ 1f,
+ 10f,
+ 10f
+ ),
+ ShiftObject(
+ ArgumentMatchers.anyLong(),
+ ShiftType.PIECE.type,
+ "Day eight",
+ "2023-08-08",
+ ArgumentMatchers.anyString(),
+ ArgumentMatchers.anyString(),
+ ArgumentMatchers.anyFloat(),
+ ArgumentMatchers.anyInt(),
+ 1f,
+ 10f,
+ 10f
+ ),
+ )
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/appttude/h_mal/farmr/viewmodel/SubmissionViewModelTest.kt b/app/src/test/java/com/appttude/h_mal/farmr/viewmodel/SubmissionViewModelTest.kt
new file mode 100644
index 0000000..2f6244b
--- /dev/null
+++ b/app/src/test/java/com/appttude/h_mal/farmr/viewmodel/SubmissionViewModelTest.kt
@@ -0,0 +1,85 @@
+package com.appttude.h_mal.farmr.viewmodel
+
+import com.appttude.h_mal.farmr.model.Success
+import io.mockk.every
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import kotlin.test.assertIs
+
+class SubmissionViewModelTest : ShiftViewModelTest() {
+
+ @Test
+ fun insertHourlyShifts_validParameters_successfulInsertions() {
+ // Arrange
+ val hourly = getHourlyShift()
+
+ // Act
+ every { repository.insertShiftIntoDatabase(hourly.copyToShift()) }.returns(true)
+ hourly.run {
+ viewModel.insertHourlyShift(description, date, rateOfPay, timeIn, timeOut, breakMins)
+ }
+
+ // Assert
+ assertIs(retrieveCurrentData())
+ assertEquals(
+ (retrieveCurrentData() as Success).successMessage,
+ "New shift successfully added"
+ )
+ }
+
+ @Test
+ fun insertPieceShifts_validParameters_successfulInsertions() {
+ // Arrange
+ val piece = getPieceRateShift()
+
+ // Act
+ every { repository.insertShiftIntoDatabase(piece.copyToShift()) }.returns(true)
+ piece.run {
+ viewModel.insertPieceRateShift(description, date, units, rateOfPay)
+ }
+
+ // Assert
+ assertIs(retrieveCurrentData())
+ assertEquals(
+ (retrieveCurrentData() as Success).successMessage,
+ "New shift successfully added"
+ )
+ }
+
+ @Test
+ fun insertHourlyShifts_validParameters_unsuccessfulInsertions() {
+ // Arrange
+ val hourly = getHourlyShift()
+
+ // Act
+ every { repository.insertShiftIntoDatabase(hourly.copyToShift()) }.returns(false)
+ hourly.run {
+ viewModel.insertHourlyShift(description, date, rateOfPay, timeIn, timeOut, breakMins)
+ }
+
+ // Assert
+ assertEquals(
+ retrieveCurrentError(),
+ "Cannot insert shift"
+ )
+ }
+
+ @Test
+ fun insertPieceShifts_validParameters_unsuccessfulInsertions() {
+ // Arrange
+ val piece = getPieceRateShift()
+
+ // Act
+ every { repository.insertShiftIntoDatabase(piece.copyToShift()) }.returns(false)
+ piece.run {
+ viewModel.insertPieceRateShift(description, date, units, rateOfPay)
+ }
+
+ // Assert
+ assertEquals(
+ retrieveCurrentError(),
+ "Cannot insert shift"
+ )
+ }
+
+}
\ No newline at end of file