Switch themes like a fox ๐ŸฆŠ based on Ambient Light ๐Ÿ’ก in your Angular Apps

Siddharth Ajmera ๐Ÿ‡ฎ๐Ÿ‡ณ - Mar 23 '20 - - Dev Community

DISCLAIMER: This is a continuation of this article where I write about implementing a theme switch in an Angular App. If you haven't already read that, I recommend you to read this first.๐Ÿ‘‡๐Ÿป

That way you'll have more context of where I'm coming from.๐Ÿ™‚

This article is intended for an audience with mixed experience levels. I've still added a TL;DR; below ๐Ÿ‘‡๐Ÿป as a precautionary measure, in case you're just interested in a specific section.

TL;DR;

To start, we'll just continue with the same Angular App that we built in the last article.

Why Dark Theme in the Dark? ๐Ÿคท๐Ÿปโ€โ™‚๏ธ

So, as I mentioned in the previous article, Dark Mode is awesome and low-lit ambiances are the best suited for #DarkMode.

Now changing themes in our App based on user interaction is OK. But we can take it to the next level by intelligently switching themes, based on the ambient lighting conditions that the user is in.

And that's exactly what we're going to do in this article.

Determining when it's dark โšก

Now you might think, how exactly do we determine that it's dark. Well, there's something called Illuminance that can help us do that.

According to Wikipedia:

Illuminance is a measure of the luminous flux spread over a given area.

One can think of luminous flux (which is measured in lumens BTW) as a measure of the total "amount" of visible light present, and the illuminance as a measure of the intensity of illumination on a surface.

"So in simpler terms, the luminous flux is inversely proportional to darkness."

For some reference, we'll use this table to determine the darkness:

Judging from the table above, it would be safe to consider that if the luminous flux is 10 or less, we're in a dark environment. But that is just a number that I've chosen. Feel free to chose a number between 10 - 20(or 50 if you like) based on your preference.

Okay, so we can determine whether the environments are light or dark based on the luminous flux. But how do we determine luminous flux? ๐Ÿค”

But how?

Enter: the AmbientLightSensor Web Interface ๐Ÿ“ก

This is a cool new interface from the Sensor APIs that returns the current light level or illuminance of the ambient light around the hosting device.

The AmbientLightSensor object has a property named illuminance on it, that returns the current light level in lux of the ambient light level around the hosting device.

It would only work on devices that have the ambient light sensor(hardware) on them(obviously). With the help of this AmbientLightSensor interface, our browsers can access the data collected by Ambient Light Sensors on devices. Cool, isn't it? ๐Ÿคฉ

What does that mean for us? Well, you can think of illuminance as the luminous flux incident on a surface.

Now we know how to get the illuminance, and from the Illuminance Table we can determine whether the environment we're in is dark or light.

So, we'll consider the environment to be dark if illuminance <= 10(again, this number is totally up to you), light otherwise.

Using the AmbientLightSensor interface to access illuminance is pretty straightforward and the usage is mentioned in this example on the MDN Docs.

But there are a lot of other things that we need to take care of while using this interface. Let's go through them one by one.

Feature Detection ๐Ÿ•ต๐Ÿปโ€โ™‚๏ธ:

This is to determine whether the browser that is running our App has the AmbientLightSensor feature on it or not. To detect this, we can simply check:

if ("AmbientLightSensor" in window) {
    // Yay! The Browser has what it takes
}
Enter fullscreen mode Exit fullscreen mode

Handling Edge Cases:

Checking whether the browser supports a feature doesn't guarantee that everything will work as expected. There might be errors:

  • While instantiation the sensor.
  • While using it.
  • When the user's permissions might be required to use the sensor.
  • When the sensor type might not be supported by the device.

So all these scenarios would result in an error. So while using this interface, we'll have to cater to all these edge cases as well.

Now that we know what we're looking at, let's try to implement this in our App.

Using the AmbientLightSensor Web Interface

Reading the illuminance and handling all these edge cases is the major task that we should delegate to a service. So let's implement a service in Angular that will handle all these things for us.

The only output that we're going to expect from this service is an Observable that either gives us the illuminance or an error message that we could show to the user. So let's do this. I'll name this service AmbientLightSensorService.

Also, since this service would also rely on the window object, let's provide it as a value so that we could then inject it as a dependency in our AmbientLightSensorService.

So in our AppModule:

app.module.ts

...
import { AmbientLightSensorService } from "./ambient-light-sensor.service";

@NgModule({
  ...
  providers: [
    AmbientLightSensorService,
    {
      provide: Window,
      useValue: window,
    },
    ...
  ]
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

There are also a lot of messages, error types, sensor policy and sensor name etc. that we are going to deal with. So let's also expose them as constants:

common.const.ts

export const SENSOR_NAME = "AmbientLightSensor";
export const SENSOR_POLICY_NAME = "ambient-light-sensor";
export const ACCESS_DENIED = "denied";
export const THEME_OPTIONS_URL = "/assets/options.json";
export const THEME_BASE_PATH = "node_modules/@angular/material/prebuilt-themes";
export const STYLE_TO_SET = "theme";
export const DARK_THEME = "pink-bluegrey";
export const LIGHT_THEME = "deeppurple-amber";

export const ERROR_TYPES = {
  SECURITY: "SecurityError",
  REFERENCE: "ReferenceError",
  NOT_ALLOWED: "NotAllowedError",
  NOT_READABLE: "NotReadableError"
};

export const ERROR_MESSAGES = {
  UNSUPPORTED_FEATURE: "Your browser doesn't support this feature",
  BLOCKED_BY_FEATURE_POLICY:
    "Sensor construction was blocked by a feature policy.",
  NOT_SUPPORTED_BY_USER_AGENT: "Sensor is not supported by the User-Agent.",
  PREMISSION_DENIED: "Permission to use the ambient light sensor is denied.",
  CANNOT_CONNECT: "Cannot connect to the sensor."
};
Enter fullscreen mode Exit fullscreen mode

Hopefully, I've named these variables in a way that they are self-explanatory.

Now let's implement this service:

ambient-light-sensor.service.ts

import { ReplaySubject, Observable } from "rxjs";
import { Injectable } from "@angular/core";

import {
  SENSOR_NAME,
  SENSOR_POLICY_NAME,
  ACCESS_DENIED,
  ERROR_TYPES,
  ERROR_MESSAGES
} from "./common.const";

@Injectable()
export class AmbientLightSensorService {
  private illuminance: ReplaySubject <number> = new ReplaySubject <number>(1);
  illuminance$: Observable<number> = this.illuminance.asObservable();

  constructor(private window: Window) {
    try {
      if (SENSOR_NAME in window) {
        this.startReading();
      } else {
        this.illuminance.error(ERROR_MESSAGES.UNSUPPORTED_FEATURE);
      }
    } catch (error) {
      // Handle construction errors.
      if (error.name === ERROR_TYPES.SECURITY) {
        this.illuminance.error(ERROR_MESSAGES.BLOCKED_BY_FEATURE_POLICY);
      } else if (error.name === ERROR_TYPES.REFERENCE) {
        this.illuminance.error(ERROR_MESSAGES.NOT_SUPPORTED_BY_USER_AGENT);
      } else {
        this.illuminance.error(`${error.name}: ${error.message}`);
      }
    }
  }

  private startReading() {
    const sensor = new AmbientLightSensor();
    sensor.onreading = () => this.illuminance.next(sensor.illuminance);
    sensor.onerror = async event => {
      // Handle runtime errors.
      if (event.error.name === ERROR_TYPES.NOT_ALLOWED) {
        // Branch to code for requesting permission.
        const result = await navigator.permissions.query({
          name: SENSOR_POLICY_NAME
        });
        if (result.state === ACCESS_DENIED) {
          this.illuminance.error(ERROR_MESSAGES.PREMISSION_DENIED);
          return;
        }
        this.startReading();
      } else if (event.error.name === ERROR_TYPES.NOT_READABLE) {
        this.illuminance.error(ERROR_MESSAGES.CANNOT_CONNECT);
      }
    };
    sensor.start();
  }
}

Enter fullscreen mode Exit fullscreen mode

The implementation caters to every edge case that we discussed in the previous section.

Basically, we've exposed the illuminance ReplaySubject<number> as the illuminance$ Observable<number>.

"Why a ReplaySubject<number>(1)?" you might ask. Well, because we don't have an initial value and so it would make more sense to use that instead of using BehaviorSubject<number>(null).

Now, we're pushing a new lux values down the illuminance ReplaySubject by calling the next method on it. And for the error cases, we're pushing out an error using the error method.

The method names and error message names are pretty self-explanatory as well. If something is still not clear, please comment down below so that I can elaborate more on it.

And so now that the service is ready, we can inject this service as a dependency in our HeaderComponent and leverage the illuminance$ Observable to get access to the lux value(or the error message).

header.component.ts

import { Component, OnDestroy, OnInit } from "@angular/core";
import { MatSnackBar } from "@angular/material/snack-bar";
import { Observable, Subject } from "rxjs";
import { takeUntil } from "rxjs/operators";

import { AmbientLightSensorService } from "../ambient-light-sensor.service";
import { DARK_THEME, LIGHT_THEME } from "../common.const";
import { Option } from "../option.model";
import { ThemeService } from "../theme.service";

@Component({
  selector: "app-header",
  templateUrl: "./header.component.html",
  styleUrls: ["./header.component.css"]
})
export class HeaderComponent implements OnInit, OnDestroy {
  options$: Observable<Array<Option>> = this.themeService.getThemeOptions();
  private unsubscribe$ = new Subject<void>();

  constructor(
    private readonly themeService: ThemeService,
    private readonly alsService: AmbientLightSensorService,
    private readonly snackBar: MatSnackBar
  ) {}

  ngOnInit() {
    this.themeService.setTheme(DARK_THEME);
    this.alsService.illuminance$
      .pipe(takeUntil(this.unsubscribe$))
      .subscribe(
        illuminance => {
          illuminance <= 10
            ? this.themeService.setTheme(DARK_THEME)
            : this.themeService.setTheme(LIGHT_THEME);
        },
        error => this.showMessage(error)
      );
  }

  themeChangeHandler(themeToSet) {
    this.themeService.setTheme(themeToSet);
  }

  ngOnDestroy() {
    this.unsubscribe$.next();
    this.unsubscribe$.complete();
  }

  private showMessage(messageToShow) {
    this.snackBar.open(messageToShow, "OK", {
      duration: 4000
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

So as you can notice:

  • We've now injected the AmbientLightSensorService as a dependency.
  • In the ngOnInit lifecycle hook, we're subscribeing to the Observable. From here:
    • The success callback gets called with the illuminance value. Here we check the illuminance value:
      • If it is <= 10, then we set the DARK_THEME.
      • If it is > 10, then we set the LIGHT_THEME.
    • The error callback gets called with the error message. From there, we're simply calling the showMessage method to show a snack bar.

Also, since we're subscribeing to the Observable this time around, we'll also have to explicitly do something to avoid any memory leaks. To do it, we go declarative by using the takeUntil operator.

Read more about this approach in this article on AngularInDepth by Tomas Trajan

And that's it. Our AmbientLightSensor Theme Switch is now ready. Let's test it out.

Trying it out ๐Ÿงช

Before we do that, there's a caveat. And it has something to do with browser support.

Data on support for the ambient-light feature across the major browsers from caniuse.com

As you can see above, browser support isn't that great at the moment. But we'll at least test this on the best browser in the world(ahem Chrome ahem).

To do that, we'll first have to enable a flag:

So I'll navigate to chrome://flags/#enable-generic-sensor-extra-classes and enable it on my phone(my laptop doesn't have Ambient Light Sensor on it). And then I'll restart the browser on my phone.

Let's now test this thing out:

Yay! It worked!

And here's the final code:

Next Steps ๐Ÿ‘ฃ

As of now, there's a slight issue in the App. What if the user doesn't want to change the theme automagically, based on the lighting conditions? We can add in a simple fix as a settings/preference menu asking to turn this behavior ON/OFF and switch the theme only when the behavior is turned ON.

Give it a try and implement the Preference/Settings Menu and then only switch the theme if the user has switched this auto-theme-switch behavior on.

Closing Notes ๐ŸŽ‰

Awwww! You're still here? Thanks for sticking around. I hope you liked it.

Iโ€™m extremely grateful to Martina Kraus, and Rajat Badjatya for taking the time to proofread it and providing all the constructive feedback in making this article better.

I hope this article has taught you something new related to Angular and Web in general. If it did, hit that ๐Ÿงก/๐Ÿฆ„ icon, and add it to your reading list(๐Ÿ”–). Also, share this article with your friends who are new to Angular/Web and want to achieve something similar.

Please stay tuned while I work on uploading a video version of this article. ๐Ÿ“บ

Icon Courtesy: Angular Material by AngularIO Press Kit | CSS by monkik from the Noun Project | Light sensor by Adnen Kadri from the Noun Project

Until next time then. ๐Ÿ‘‹๐Ÿป

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .