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