← THE INDEX  ·  PRODUCT

Matcha Bloom

A local-first iOS journal for tracking matcha visits across Portland, with a shareable card and a cafe discovery map.

Matcha Bloom

Private repository. No API keys, tokens, or user data appear in this writeup.

What it is

Matcha Bloom is a personal iOS app for logging matcha drinks at Portland cafes. The core interaction is fast: a 10-second bottom sheet lets you pick the cafe, select from its signature-drink chips, star the rating, add tags, and save. Photo and extended notes can be added after. Everything is stored locally with SharedPreferences; there are no accounts and no telemetry.

The V2 release shipped 25 Portland cafes pre-seeded as map pins in a "want to try" state, so the map is useful on day one. A manual "Add a place" flow accepts an address and geocodes it. The cafe list ships as a bundled JSON (assets/pdx_cafes.json) loaded at startup.

Design and features

The app uses Cupertino transitions and semantic haptics throughout to feel native on iOS. The color system is built around a custom matcha green palette (_matcha / _deepMatcha) with warm cream backgrounds, defined as top-level constants and threaded into a custom ThemeData applied at app root.

V2 added four social features: a shareable 1080x1920 Matcha Card rendered via RenderRepaintBoundary and exported through the iOS share sheet (Instagram Story background when a Meta App ID is configured); a monthly Bloom progress view; "On This Day" memories surfaced from past entries; and a swipeable monthly Petals recap. The map supports All / Visited / Want-to-try filtering.

The Matcha Card: off-screen rendering to PNG

The shareable Matcha Card is a 1080x1920 Flutter widget (MatchaShareCard) that lives off-screen; it's never part of the app's visible widget tree. When the user taps Share, a GlobalKey is used to find the RenderRepaintBoundary wrapping the card, toImage(pixelRatio: 1.0) rasterises it, toByteData(format: png) encodes it, and the resulting bytes are written to a temp file. share_plus hands that file to the iOS share sheet.

The card layout is custom: a _CupAndFlowerPainter (a CustomPainter) draws a decorative bottom panel; rating shows as petal icons (0-5 Icons.local_florist); the drink name, cafe, and visit date are laid out with explicit TextStyle weights and sizes at 1080px logical width, so it looks crisp on any device screen that opens it.

lib/main.dart: off-screen PNG capture for the share sheet
Future<File?> _capturePngFile(GlobalKey key) async {
  final boundary = key.currentContext?.findRenderObject() as RenderRepaintBoundary?;
  if (boundary == null) return null;
  final image = await boundary.toImage(pixelRatio: 1.0);
  final data = await image.toByteData(format: ui.ImageByteFormat.png);
  if (data == null) return null;
  final dir = await getTemporaryDirectory();
  final file = File(
    '${dir.path}${Platform.pathSeparator}'
    'matcha_share_${DateTime.now().millisecondsSinceEpoch}.png',
  );
  await file.writeAsBytes(data.buffer.asUint8List(), flush: true);
  return file;
}

Monthly Petals recap

At the start of each month, if at least 4 visits were logged the previous month, shouldAutoShowRecap returns true and the app presents a swipeable PetalsRecapScreen. The screen is built from a list of _RecapSpec objects constructed in _buildSpecs(): it pulls the month's entries, computes top-rated drink, most-visited cafe, average rating, and tag frequency, then assembles a sequence of full-screen story-style slides (kicker + big text + caption) one per stat, closing with a summary card.

Each slide has its own GlobalKey so it can be independently captured to PNG and shared. The recap auto-show fires once per month, tracked via a SharedPreferences key storing the last shown month tag (yyyy-MM).

lib/main.dart: MatchaStore: seed loading, delta persistence, and recap gating
Future<void> _loadSeedDirectoryIfNeeded(SharedPreferences prefs) async {
  // Treat a legacy boolean flag as version 1 so existing installs still pick
  // up cafes added in later seed versions, without re-adding removed ones.
  final loadedVersion = prefs.getInt(_seedVersionKey)
      ?? ((prefs.getBool(_seedFlagKey) ?? false) ? 1 : 0);
  if (loadedVersion >= _seedVersion) return;
  final seeds = await _readSeedCafes();
  final existingIds = _entries.map((e) => e.id).toSet();
  for (final seed in seeds) {
    if (!existingIds.contains(seed.id)) _entries.add(seed);
  }
  await prefs.setBool(_seedFlagKey, true);
  await prefs.setInt(_seedVersionKey, _seedVersion);
  await _persist();
}

Future<bool> shouldAutoShowRecap(DateTime forMonth) async {
  if (!_monthlyRecapEnabled) return false;
  final now = DateTime.now();
  if (now.day < 1 || now.day > 5) return false;
  if (visitedInMonth(forMonth.year, forMonth.month) < 4) return false;
  final tag = DateFormat('yyyy-MM').format(forMonth);
  final prefs = await SharedPreferences.getInstance();
  if (prefs.getString(_lastRecapKey) == tag) return false;
  return true;
}

Build and distribution

The build authority is GitHub Actions: the workstation does not run Flutter directly. A CI workflow triggers on TestFlight-tagged commits, runs flutter analyze and flutter test, builds the iOS archive, and uploads to TestFlight for distribution. The pubspec.yaml locks Flutter SDK to ^3.11.5 and pins all package versions. The app has no network dependencies at runtime beyond the OpenStreetMap tile layer.

Backup and restore are built in: a settings sheet exposes Export (serializes entries + base64-encoded photos into a single JSON file, shared via the iOS sheet) and Import (merges or replaces the journal). The import path validates that the file contains the expected matcha_bloom_backup marker before touching local state.