- mid commit

This commit is contained in:
2023-08-28 21:13:43 +01:00
parent a614dfe543
commit 07d7e6cbe7
29 changed files with 1646 additions and 457 deletions

View File

@@ -44,7 +44,7 @@ dependencies {
androidTestImplementation 'androidx.test:rules:1.4.0'
/ * mockito and livedata testing * /
testImplementation 'org.mockito:mockito-inline:2.13.0'
implementation 'androidx.arch.core:core-testing:2.1.0'
testImplementation 'androidx.arch.core:core-testing:2.1.0'
/ * MockK * /
def mockk_ver = "1.10.5"
testImplementation "io.mockk:mockk:$mockk_ver"

View File

@@ -0,0 +1,91 @@
package com.appttude.h_mal.farmr.data.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.test.core.app.ActivityScenario
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.di.ShiftApplication
import kotlinx.coroutines.runBlocking
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.junit.After
import org.junit.Before
import org.junit.Rule
@Suppress("EmptyMethod")
open class BaseTest<A : Activity>(
private val activity: Class<A>,
private val intentBundle: Bundle? = null,
) {
lateinit var scenario: ActivityScenario<A>
private lateinit var testApp: ShiftApplication
private lateinit var testActivity: Activity
private lateinit var decorView: View
@get:Rule
var permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_EXTERNAL_STORAGE)
@Before
fun setUp() {
val startIntent =
Intent(InstrumentationRegistry.getInstrumentation().targetContext, activity)
if (intentBundle != null) {
startIntent.replaceExtras(intentBundle)
}
testApp =
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ShiftApplication
runBlocking {
beforeLaunch()
}
scenario = ActivityScenario.launch(startIntent)
scenario.onActivity {
decorView = it.window.decorView
testActivity = it
}
afterLaunch()
}
fun getActivity() = testActivity
@After
fun tearDown() {
testFinished()
}
open fun beforeLaunch() {}
open fun afterLaunch() {}
open fun testFinished() {}
fun waitFor(delay: Long) {
Espresso.onView(ViewMatchers.isRoot()).perform(object : ViewAction {
override fun getConstraints(): Matcher<View> = 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)
}
}
}

View File

@@ -0,0 +1,161 @@
package com.appttude.h_mal.farmr.data.ui
import android.content.res.Resources
import android.widget.DatePicker
import android.widget.TimePicker
import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.test.espresso.Espresso.onData
import androidx.test.espresso.Espresso.onView
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.ViewMatchers.*
import com.appttude.h_mal.farmr.data.ui.utils.EspressoHelper.waitForView
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.anything
import org.hamcrest.CoreMatchers.equalTo
@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 <VH : ViewHolder> scrollToRecyclerItem(recyclerId: Int, text: String): ViewInteraction? {
return matchView(recyclerId)
.perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.scrollTo<VH>(
hasDescendant(withText(text))
)
)
}
fun <VH : ViewHolder> scrollToRecyclerItem(
recyclerId: Int,
resIdForString: Int
): ViewInteraction? {
return matchView(recyclerId)
.perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.scrollTo<VH>(
hasDescendant(withText(resIdForString))
)
)
}
fun <VH : ViewHolder> scrollToRecyclerItemByPosition(
recyclerId: Int,
position: Int
): ViewInteraction? {
return matchView(recyclerId)
.perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.scrollToPosition<VH>(position)
)
}
fun <VH : ViewHolder> clickViewInRecycler(recyclerId: Int, text: String) {
matchView(recyclerId)
.perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.actionOnItem<VH>(hasDescendant(withText(text)), click())
)
}
fun <VH : ViewHolder> clickViewInRecycler(recyclerId: Int, resIdForString: Int) {
matchView(recyclerId)
.perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.actionOnItem<VH>(
hasDescendant(withText(resIdForString)),
click()
)
)
}
fun <VH : ViewHolder> clickViewInRecyclerAtPosition(recyclerId: Int, position: Int) {
matchView(recyclerId)
.perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.scrollToPosition<VH>(position),
RecyclerViewActions.actionOnItemAtPosition<VH>(position, click())
)
}
fun <VH : ViewHolder> clickOnRecyclerItemWithText(recyclerId: Int, text: String) {
scrollToRecyclerItem<VH>(recyclerId, text)
?.perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.actionOnItem<VH>(
withChild(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
)
)
}
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
)
)
}
}

View File

@@ -0,0 +1,41 @@
package com.appttude.h_mal.farmr.data.ui.robots
import com.appttude.h_mal.farmr.R
import com.appttude.h_mal.farmr.data.ui.BaseTestRobot
import com.appttude.h_mal.farmr.model.ShiftType
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)
}

View File

@@ -0,0 +1,29 @@
package com.appttude.h_mal.farmr.data.ui.robots
import com.appttude.h_mal.farmr.R
import com.appttude.h_mal.farmr.data.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)
}

View File

@@ -0,0 +1,26 @@
package com.appttude.h_mal.farmr.data.ui.robots
import com.appttude.h_mal.farmr.R
import com.appttude.h_mal.farmr.base.BaseRecyclerAdapter.CurrentViewHolder
import com.appttude.h_mal.farmr.data.ui.BaseTestRobot
fun homeScreen(func: HomeScreenRobot.() -> Unit) = HomeScreenRobot().apply { func() }
class HomeScreenRobot : BaseTestRobot() {
fun clickOnItem(position: Int) = clickViewInRecyclerAtPosition<CurrentViewHolder>(R.id.list_item_view, position)
fun clickOnItemWithText(text: String) = clickOnRecyclerItemWithText<CurrentViewHolder>(R.id.list_item_view, text)
fun clickOnEdit(position: Int) = clickViewInRecycler<CurrentViewHolder>(R.id.list_item_view, R.id.imageView)
fun clickFab() = clickButton(R.id.fab1)
fun clickOnInfo() = clickButton(R.id.action_favorite)
// fun clearFilter() =
// fun applySort() =
// fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location)
// fun refresh() = pullToRefresh(R.id.swipe_refresh)
// fun isDisplayed() = matchViewWaitFor(R.id.temp_main_4)
// fun verifyUnableToRetrieve() {
// matchText(R.id.header_text, R.string.retrieve_warning)
// matchText(R.id.body_text, R.string.empty_retrieve_warning)
// }
}

View File

@@ -0,0 +1,33 @@
package com.appttude.h_mal.farmr.data.ui.robots
import com.appttude.h_mal.farmr.R
import com.appttude.h_mal.farmr.data.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)
}

View File

@@ -0,0 +1,77 @@
package com.appttude.h_mal.farmr.data.ui.tests
import com.appttude.h_mal.farmr.data.ui.BaseTest
import com.appttude.h_mal.farmr.data.ui.robots.addScreen
import com.appttude.h_mal.farmr.data.ui.robots.homeScreen
import com.appttude.h_mal.farmr.data.ui.robots.viewScreen
import com.appttude.h_mal.farmr.model.ShiftType
import com.appttude.h_mal.farmr.ui.MainActivity
import org.junit.Test
class ShiftTests: BaseTest<MainActivity>(MainActivity::class.java) {
// Add a shift successfully
@Test
fun test1() {
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 {
sc("This is a description")
}
}
// Edit a shift successfully
@Test
fun test2() {
homeScreen {
clickOnItemWithText("Edit this shift")
}
addScreen {
setRateOfPay(20f)
assertDuration("2.0 hours")
assertTotalPay("£40.00")
submit()
}
homeScreen {
clickOnItemWithText("Edit this shift")
}
viewScreen {
matchDescription("Edit this shift")
matchDuration("2 Hours 0 minutes")
matchTotalPay("2.0 hours @ £20.00 per Hour\nEquals:£40.00")
}
}
// filter the list with date from
@Test
fun test3() {}
// filter the list with date to
@Test
fun test4() {}
// Add a shift as piece rate
@Test
fun test5() {}
// Validate the details screen
@Test
fun test6() {}
// filter, sort, order and then reset
@Test
fun test7() {}
}

View File

@@ -0,0 +1 @@
package com.appttude.h_mal.farmr.data.ui.utils

View File

@@ -0,0 +1,123 @@
package com.appttude.h_mal.farmr.data.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<View>): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View> = 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<View> = 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<View> {
return object : BaseMatcher<View>() {
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<View>,
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")
}
}

View File

@@ -0,0 +1,32 @@
package com.appttude.h_mal.farmr.data.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 <T> LiveData<T>.getOrAwaitValue(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS
): T {
var data: T? = null
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
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
}

View File

@@ -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<V : BaseViewModel> : AppCompatActivity(), KodeinAware {
override val kodein by kodein()
private val factory by instance<ApplicationViewModelFactory>()
val viewModel: V by getViewModel()
private fun getViewModel(): Lazy<V> =
ViewModelLazy(getGenericClassAt(0), storeProducer = { viewModelStore },
factoryProducer = { factory } )
abstract class BaseActivity : AppCompatActivity() {
/**

View File

@@ -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<V : BaseViewModel>(@LayoutRes contentLayoutId: Int)
override val kodein by kodein()
private val factory by instance<ApplicationViewModelFactory>()
val viewModel: V by getActivityViewModel()
val viewModel: V by getViewModel()
private fun getActivityViewModel() = createViewModelLazy<V>(
getGenericClassAt(0),
{ requireActivity().viewModelStore },
{ factory })
private fun getViewModel(): Lazy<V> =
ViewModelLazy(getGenericClassAt(0), storeProducer = { viewModelStore },
factoryProducer = { factory } )
var mActivity: BaseActivity<*>? = null
var mActivity: BaseActivity? = null
private var shortAnimationDuration by Delegates.notNull<Int>()
@@ -39,7 +40,7 @@ abstract class BaseFragment<V : BaseViewModel>(@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<V : BaseViewModel>(@LayoutRes contentLayoutId: Int)
}
fun setTitle(title: String) {
(requireActivity() as BaseActivity<*>).setTitleInActionBar(title)
(requireActivity() as BaseActivity).setTitleInActionBar(title)
}
fun popBackStack() = mActivity?.popBackStack()

View File

@@ -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,8 +56,8 @@ class PreferenceProvider(
fun getFilteringDetails(): Map<String, String?> {
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))
)
}

View File

@@ -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<MainViewModel>(R.layout.fragment_filter_data),
class FilterDataFragment : BaseFragment<FilterViewModel>(R.layout.fragment_filter_data),
AdapterView.OnItemSelectedListener, OnClickListener {
private val spinnerList: Array<String> =
arrayOf("", ShiftType.HOURLY.type, ShiftType.PIECE.type)
@@ -26,10 +26,10 @@ class FilterDataFragment : BaseFragment<MainViewModel>(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<MainViewModel>(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<MainViewModel>(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<MainViewModel>(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?) {

View File

@@ -26,8 +26,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<MainViewModel>(R.layout.fragment_add_item),
class FragmentAddItem : BaseFragment<SubmissionViewModel>(R.layout.fragment_add_item),
RadioGroup.OnCheckedChangeListener, BackPressedListener {
private lateinit var mHourlyRadioButton: RadioButton
@@ -262,7 +263,6 @@ class FragmentAddItem : BaseFragment<MainViewModel>(R.layout.fragment_add_item),
StringBuilder().append(mDuration).append(" hours").toString()
mDuration!! * mPayRate
}
ShiftType.PIECE -> {
(mUnits ?: 0f) * mPayRate
}

View File

@@ -70,7 +70,6 @@ class FragmentMain : BaseFragment<MainViewModel>(R.layout.fragment_main), BackPr
override fun onStart() {
super.onStart()
viewModel.refreshLiveData()
}
@@ -112,7 +111,7 @@ class FragmentMain : BaseFragment<MainViewModel>(R.layout.fragment_main), BackPr
}
R.id.clear_filter -> {
viewModel.setFiltrationDetails(null, null, null, null)
viewModel.clearFilters()
return true
}

View File

@@ -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<MainViewModel>(R.layout.fragment_futher_info) {
class FurtherInfoFragment : BaseFragment<InfoViewModel>(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<MainViewModel>(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()
}
}

View File

@@ -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<MainViewModel>() {
class MainActivity : BaseActivity() {
private lateinit var toolbar: Toolbar
override fun onCreate(savedInstanceState: Bundle?) {

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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"))
}
}

View File

@@ -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()
}
}

View File

@@ -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<List<ShiftObject>>()
val shiftLiveData: LiveData<List<ShiftObject>> = _shiftLiveData
private val shiftLiveData: LiveData<List<ShiftObject>> = _shiftLiveData
private var mSort: Sortable = Sortable.ID
private var mOrder: Order = Order.ASCENDING
private var mFilterStore: FilterStore? = null
private val observer = Observer<List<ShiftObject>> {
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

View File

@@ -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]
)
}
}

View File

@@ -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
}
}

View File

@@ -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<ShiftObject>()
// 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<String, String?> =
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
),
)
}

View File

@@ -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<V : ShiftViewModel> {
@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
),
)
}

View File

@@ -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<SubmissionViewModel>() {
@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<Success>(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<Success>(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"
)
}
}