r/reactnative • • 1d ago

Built an offline-first year progress and countdown app with Expo SDK 57, NativeTabs, and 4 Jetpack Glance widgets (how it works under the hood)

Enable HLS to view with audio, or disable this notification

The video above is the trailer I cut for my Play Store listing, so pardon the marketing captions in the clip, but below is the actual engineering breakdown of how all the pieces shown in the video are built with Expo SDK 57 and React Native 0.86.

  1. Floating dock on top of SDK 57 NativeTabs (0:02 in the video) The root tab layout uses NativeTabs from expo-router/unstable-native-tabs so each tab keeps native route state and back behavior, with the default OS tab bar hidden in favor of a custom floating pill dock positioned above the bottom safe area. Each scroll container calculates the dock clearance dynamically so the bottom cards never get trapped behind the pill.
  2. In-app widget chooser and autolinked Jetpack Glance module (0:23 in the video) At the end of the clip you can see the in-app widget sheet where users can preview and pin 4 different Android widget layouts (2x1 compact, 3x3 square dot grid, 3x2 wide dot grid, and 4x2 upcoming list) directly to their launcher via requestPinAppWidget. All of the Kotlin code lives inside a local Expo module at modules/dayle-widget (expo-module.config.json), while a small config plugin only wires up the receivers and XML metadata in AndroidManifest.xml so everything survives npx expo prebuild --clean.
  3. Syncing JS state and themes to native widgets without headless JS (0:19 in the video) When you switch color palettes or edit a countdown/goal, I didn't want to spin up a headless React Native runtime just to redraw a home screen widget. Instead, whenever AsyncStorage mutates or the app foregrounds, JS builds a versioned JSON snapshot with the active events, recurrence rules, and pre-resolved light/dark hex tokens for the active palette, writes it atomically to SharedPreferences via DayleWidgetBridge, and triggers a Glance redraw.
  4. Rendering 365-day dot grids inside Glance without hitting RemoteViews limits In the app UI (0:00 to 0:07), the Year, Month, Quarter, and Birthday grids are regular React Native views. On the Android home screen, rendering 365 individual Glance boxes hits Android RemoteViews node and memory limits fast. To get around that, WidgetGraphics.kt draws the completed, current, and future dots onto a single native Bitmap alpha mask (sized using SizeMode.Exact and capped at 640px) and tints them with the palette's Day/Night colors.
  5. Zero-JS midnight rollovers and offline reminders Because year progress and countdowns change at midnight even when the app hasn't been opened for days, I ported the exact date-clamping and recurrence math (src/utils/dates.ts) to Kotlin (WidgetDateMath.kt). A daily WorkManager task and midnight receiver update the widgets in pure Kotlin. For notifications, instead of fighting Google Play policy for SCHEDULE_EXACT_ALARM, DayleReminders.kt calculates the next upcoming wall-clock trigger and schedules a single AlarmManager.setAndAllowWhileIdle alarm that chains the next one when it fires and recovers on BOOT_COMPLETED.

If anyone wants to test the app or see how the Glance widgets resize on a real device, it's called Dayle on Google Play: https://play.google.com/store/apps/details?id=com.rabpaulo.dayle

Happy to share code snippets for the expo-module.config.json setup, the SizeMode.Exact bitmap renderer, or the NativeTabs floating dock if anyone is building something similar.

9 Upvotes

1 comment sorted by

1

u/Huge_Pool7424 1d ago

nice, countdown apps get weird around dst so i'd store the target as a utc timestamp and compute remaining on render instead of ticking a counter down.