Kurt CapatanMobile Dev
← All work

Fueled · iFIT

iFIT app icon

iFIT Android Tablet App

Built dashboard sections and a reusable test library for a connected-fitness app.

Role
Senior Android Developer
When
2021 – 2022
Platforms
Android, Tablet
Industry
Fitness
Release
Released on Google Play.
  • Kotlin
  • XML
  • RecyclerView
  • Epoxy
  • Espresso

About the app

iFIT is a connected-fitness platform with trainer-led workouts. I worked on the dashboard of its Android tablet app, where members find featured workouts, browse categories, join challenges and resume a paused workout.

What I built

  • Dashboard sections for featured workouts, workout categories, challenges, and resuming a paused workout — built with Airbnb’s Epoxy on top of nested RecyclerViews, so each section is a model and adding or reordering one is a small change in buildModels().
  • A reusable Espresso library for testing RecyclerView items, at iFIT’s request, so other teams could test their list screens the same way. Espresso’s built-in actions can act on a whole item but not easily on one view inside it, or on a list inside a list. I wrote custom Matchers and ViewActions that find a view inside the item at a given position, reach into a nested inner list, and act on a single child view.
How a matcher reaches into a list inside a list

I no longer have the iFIT code, so this is the approach rewritten as a sketch. A custom Matcher finds one view inside an inner list’s item, inside an outer list’s item:

fun nestedItemView(
    @IdRes outerListId: Int,
    outerPosition: Int,
    @IdRes innerListId: Int,
    innerPosition: Int,
    @IdRes targetViewId: Int,
): Matcher<View> = object : TypeSafeMatcher<View>() {
    override fun describeTo(description: Description) {
        description.appendText(
            "view $targetViewId in inner item $innerPosition of outer item $outerPosition",
        )
    }

    override fun matchesSafely(view: View): Boolean {
        val outerList = view.rootView.findViewById<RecyclerView>(outerListId) ?: return false
        val outerItem = outerList.findViewHolderForAdapterPosition(outerPosition)?.itemView
            ?: return false
        val innerList = outerItem.findViewById<RecyclerView>(innerListId) ?: return false
        val innerItem = innerList.findViewHolderForAdapterPosition(innerPosition)?.itemView
            ?: return false
        return view === innerItem.findViewById<View>(targetViewId)
    }
}

A view holder exists only while its item is laid out, so the test scrolls the outer list first:

onView(withId(R.id.dashboard))
    .perform(scrollToPosition<RecyclerView.ViewHolder>(2))
onView(nestedItemView(R.id.dashboard, 2, R.id.workouts, 0, R.id.title))
    .check(matches(withText("Morning Run")))
    .perform(click())