The WarmAndFuzzy mobile app

2/3/2020 in warm-and-fuzzy
« The WarmAndFuzzy web app Building a full-stack monorepo engineering system »

Show me what you got

Logging in

Prior to authenticating I take the opportunity to display my "fancy" "brand" imaging.

The login happens on an Auth0 web page hosted in an in-app browser - it's a bit jarring transitioning into this from the otherwise dark mode-ish app, but whatever.

Waiting to log in
Waiting to log in
Logging in with Auth0
Logging in with Auth0

Status

The main page gives a quick run-down of what every thermostat is up to, including a description of what setpoints they're chasing and what actions they're taking.

Unlike the webapp, settings for each thermostat can be changed by clicking on any thermostat from the home page. This felt like a more touch-friendly UX than porting the same concepts (and code, perhaps) from the web version.

Thermostats' latest values, actions, and setpoints
Thermostats' latest values, actions, and setpoints
Settings for a single thermostat
Settings for a single thermostat

Settings

Touching any one setting opens up a dedicated screen for editing a given setting.

At the bottom of the screen for editing a setting is a Remove and Save button. When the setting is unchanged, the Remove button is enabled and when clicked will remove the setting without any further confirmation. Once the setting has been changed, the Save button is enabled and will similarly save the setting without any further confirmation; the Remove button becomes disabled when the setting is changed to avoid letting a user accidentally delete a setting when they meant to save it. Navigating back before saving just discards the edits.

Editing a scheduled setting
Editing a scheduled setting
Save button enabled for a modified setting
Save button enabled for a modified setting

Changing the time of day for a scheduled setting is nice and Android-ish courtesy of @react-native-community/datetimepicker:

Editing a setting's time of day
Editing a setting's time of day

Preferences

Tapping the person-shaped icon at the top right of the home screen gets to the Account screen, effectively a mix of account information, account preferences, and various debugging information.

The Account screen
The Account screen

How it's built

The web app is built using React and uses react-native-paper as its foundational UX element library.

The data and state management is the same as for the web app and the level of code sharing is basically just glorious.

In-app navigation

I use react-navigation for managing in-app navigation. It's ... a bit of a trip. It all makes good sense until you want to configure the navigation options per screen and then things go a bit weird.

After much back-and-forth, the best approach seems to be to articulate a screen as implementing NavigationStackScreenComponent<> rather than React.FunctionComponent<>, and then providing a static navigationOptions function like so:

const HomeScreen: NavigationStackScreenComponent<{}> = (): React.ReactElement => {
  return (
    <BaseView>
      <ThermostatStatusTable />
    </BaseView>
  );
};

HomeScreen.navigationOptions = ({
  navigation,
  screenProps,
}: {
  navigation: NavigationScreenProp<NavigationRoute<{}>, {}>;
  screenProps: any;
}): NavigationStackOptions => ({
  title: "Home",
  headerRight: (): React.ReactNode => (
    <IconButton
      color={(screenProps as ScreenProps).theme.colors.text}
      icon="account"
      onPress={(): void => {
        navigation.navigate(ScreenRoutes.Account);
      }}
    />
  ),
});

This means you can't access state or computed values of a screen in its header and my brain melted a bit thinking about how implementing something like a Save button in the screen header could be implemented. Ah well.

A nicer model might be to have the rendered elements include an element that defines non-static navigation options, but that may just violate the top-down flow paradigm in React. I don't know what the right thing to do here is but it feels like dogma is taking precedence over practicality a little.

Also don't get me started on passing parameters to screens (note also that parameters and options are different things).

I can type this well enough by passing my desired parameters as a generic parameter to NavigationStackScreenComponent:

export interface ThermostatNavigationParams extends NavigationParams {
  thermostatId: string;
  thermostatName: string;
}

const ThermostatSettingsScreen: NavigationStackScreenComponent<ThermostatNavigationParams> = ({
  navigation,
}): React.ReactElement => {
  const rootStore = useRootStore();

  const thermostatSettings = rootStore.thermostatSettingsStore.findById(
    navigation.state.params?.thermostatId ?? "0"
  );

  ...

...but why must they show up in navigation.state.params (awkward) and then also as optional (wat)? This is another one of those TypeScript/JavaScript "nothing is ever guaranteed to work even if it compiles" type things and it some ways I appreciate that the optional params acknowledge that reality, but it's also just irritating.

At least the new-ish thing?.maybePresentProperty chaining in JavaScript makes this faster to type through.

Uploading to the Google Play store

Note: since our family is entirely Android-based, I only bothered standing up the Android version of this React Native app. Given how extra it is to get any one platform and store working, I'm not particularly interested in making this work on iOS just for the fun of it.

Boy howdy is it hard to distribute an app to four people (the current target audience for WarmAndFuzzy). Automating that is even more ... fun.

Step 1: modify android/app/build.gradle to load keystore properties from a local file:

/**
 * Load keystore secrets, if available
 */
def keystorePropsFile = file('waf-upload-key.password.decrypted')

Properties keystoreProps = new Properties()

if (keystorePropsFile.exists()) {
    keystoreProps.load(new FileInputStream(keystorePropsFile))
}

android {
  ...
    signingConfigs {
        debug {
            storeFile file('debug.keystore')
            storePassword 'android'
            keyAlias 'androiddebugkey'
            keyPassword 'android'
        }
        release {
            storeFile file('waf-upload-key.keystore.decrypted')
            keyAlias 'waf'
            storePassword keystoreProps['keystore.password']
            keyPassword keystoreProps['keystore.password']
        }
    }
}

(I'll talk about the *.decrypted files in another post.)

Step 2: light up gradle-play-publisher

Follow the instructions about service accounts and follow - if I recall correctly - the legacy path for getting P12 certs or somesuch. It's confusing.

Then again in android/app/build.gradle:

apply plugin: "com.github.triplet.play"

play {
    // See https://github.com/Triple-T/gradle-play-publisher
    serviceAccountEmail = "warmandfuzzyci@api-5463003342924434615-284913.iam.gserviceaccount.com"
    serviceAccountCredentials = file("google-play-service-account.p12.decrypted")

    resolutionStrategy = "auto"
}

And in android/build.gradle:

plugins {
    id 'com.github.triplet.play' version '2.3.0'
}

Step 3: auto-create release notes

I wrote this little script to generate release notes for local and CI builds so the whole thing just runs automagically.

Step 4: call ./gradlew publishReleaseBundle from CI

...and Bob's your uncle.

Other tips and tricks

The Auth service uses react-native-keychain to store ID and access tokens - not really what that library was intended for but it works well.

StyleSheet is dope and I want it in React, not just React Native:

const styles = StyleSheet.create({
  // Primary row (e.g. "Sensor [temp] [hum]")
  primaryRow: {
    flex: 1,
    flexDirection: "row",
    alignItems: "center",
    height: 30,
  },
  ...
};

I especially appreciate that eslint makes sure there are no unused styles declared - insta-gardening!

Lessons learned

React Native is a pretty magical piece of kit. The ~1 second latency from my hitting CTRL-S to the emulator running updated code is pretty magical. +++ would buy again.

That said, it does a lot at runtime that anyone who's used to compiled languages would have expected to be a compile-time activity. This means that major oopsies can go entirely unnoticed.

The most irritating episode was when I lit up flatbuffers support in the shared-client library. For whatever reason, the JavaScript Google provides does some weird module loading goop that I can't quite comprehend at the end:

// Exports for Node.js and RequireJS
this.flatbuffers = flatbuffers;

The whole module handling / exporting universe of JavaScript and TypeScript is just bananas, and for whatever reason, the approach taken above worked fine in my shared-client package and in my webapp package, but not in mobile.

Hilariously (not), the mobile package built fine and deployed fine all the way through Google Play, except opening the app just got me this:

ReactNative shits the bed
ReactNative shits the bed

And of course I can't be bothered to have any actual UI tests (preferring to invest my time in compile-time type safety instead), so I managed to break an actual deploy to the Play Store without noticing that the app was completely dead in the water.

It still boggles the mind that I can run an entire app through build, CI/CD, and deploy, and it's fucking dead in the water. That's really something.

For what it's worth, I ended up "rewriting" Flatbuffers in TypeScript by taking the TypeScript type definitions for Flatbuffers from @types/flatbuffers, fleshing them out with the Google-provided JavaScript code, and fixing up assorted TypeScript problems. I don't quite understand why Google doesn't do this but the project may just be too dead at this time.

And while I'm complaining,

  • the Android toolchain is a pain in the ass to babysit (let alone update);
  • making an Android emulator work on modern Windows with Hyper-V is super-extra and requires clowning around with Visual Studio (come on, Google, get it together);
  • and any environment changes (e.g. going from npm run start-mobile:local:dev to npm run start-mobile:remote:prod) require a deep clean (lerna run clean-mobile, i.e. ./gradlew clean) for whatever reasons. For all the effort it takes to make gradle do its thing (and boy howdy is that thing extra), it feels like it should get that sort of dependency tracking right. It does not seem to.

With all those complaints, the mobile app is probably my least favorite part of the project to work on; on the other hand, it's pretty awesome to get to write a bit of code and then hold it in my hand five minutes later. Painful but worth it.

« The WarmAndFuzzy web app Building a full-stack monorepo engineering system »