/

AI & LLM

Dec 9, 2025

Dec 9, 2025

Building a daily productivity app with Pieces — Part 4: polish & production ready

Build a daily productivity app with Pieces in Part 4 of the series: polish the UX, tighten performance, fix edge cases, and make your app production-ready with deployment best practices.

Welcome to Part 4! We've come a long way:

  • Part 1: We built a real-time sync with Pieces OS

  • Part 2: Then we added AI insights with Gemini

  • Part 3: Lastly, we created a beautiful Flutter UI


The problems we're solving

After Part 3, we had a working app, but:

  1. Every restart regenerates recaps - Wastes API calls and time

  2. Errors just fail silently - Bad UX

  3. Can't regenerate a recap - Stuck with what you got

  4. No tests - Can't confidently make changes

  5. No CI/CD - Manual testing every time

Let's fix all of these!


Feature 1: persistent caching with Hive

The problem

When you close the app, all those AI-generated recaps? Gone. Next time you open it, we regenerate everything. That's:

  • Slow (3+ seconds per day)

  • Expensive (API calls cost money)

  • Wasteful (Same data, different day)

Solution: persistent caching with Hive

Hive is a lightweight, fast NoSQL database for Flutter. Perfect for caching our recaps!

First, we need to update our model file lib/models/daily_recap_models.dart with Hive annotations:

// Place the import & part at the top of the Dart file

import 'package:hive/hive.dart';

part 'daily_recap_models.g.dart';  // Generated file

// Replace existing 'class DailyRecapData'. Stopping at the factory constructor.

@HiveType(typeId: 0)
class DailyRecapData extends HiveObject {
  @HiveField(0)
  final DateTime date;

  @HiveField(1)
  final String summary;

  @HiveField(2)
  final List<String> people;

  @HiveField(3)
  final List<ProjectData> projects;

  @HiveField(4)
  final List<String> reminders;

  @HiveField(5)
  final List<String> notes;

DailyRecapData({
    required this.date,
    required this.summary,
    required this.people,
    required this.projects,
    required this.reminders,
    required this.notes,
  });
  // Constructor, fromJson, toJson...
}

// Replace existing 'class ProjectData'

@HiveType(typeId: 1)
class ProjectData {
  @HiveField(0)
  final String name;

  @HiveField(1)
  final String description;

  @HiveField(2)
  final ProjectStatus status;
  
    ProjectData({
    required this.name,
    required this.description,
    required this.status,
  });
  
  
}

// Replace the existing 'enum ProjectStatus'

@HiveType(typeId: 2)
enum ProjectStatus {
  @HiveField(0)
  completed,

  @HiveField(1)
  inProgress,

  @HiveField(2)
  notStarted,
}

Keep in mind:

  • @HiveType(typeId: X) - Unique ID for each type

  • @HiveField(X) - Field index for serialization

  • extends HiveObject - Makes it a Hive object

Adding Hive Dependencies

Now we need to add Hive to our pubspec.yaml:

dependencies:
  hive: ^2.2.3
  hive_flutter: ^1.1.0

dev_dependencies:
  hive_generator: ^2.0.1
  build_runner: ^2.4.13
flutter pub get && flutter pub run build_runner build --delete-conflicting-outputs

This generates daily_recap_models.g.dart with type-safe adapters!

Updating DailyRecapService

Now we need to update our DailyRecapService in daily_recap_service.dart to use Hive. Add these new methods:

// Add to top of file, where imports (if any) are

import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';

// Replace the DailyRecapService class

class DailyRecapService {
  final GenerativeModel _model;
  late final Box<DailyRecapData> _cacheBox;  // NEW: Hive cache box

   DailyRecapService({required String apiKey})
    : _model = GenerativeModel(
        model: 'gemini-2.5-pro',
        apiKey: apiKey,
      );

  /// NEW: Initialize Hive and open the cache box
  Future<void> initialize() async {
    await Hive.initFlutter();

    // Register adapters
    if (!Hive.isAdapterRegistered(0)) {
      Hive.registerAdapter(DailyRecapDataAdapter());
    }
    if (!Hive.isAdapterRegistered(1)) {
      Hive.registerAdapter(ProjectDataAdapter());
    }
    if (!Hive.isAdapterRegistered(2)) {
      Hive.registerAdapter(ProjectStatusAdapter());
    }
    
    // Open typed box
    _cacheBox = await Hive.openBox<DailyRecapData>('daily_recaps_cache');
  }

  DailyRecapData? getCachedRecap(DateTime date) {
    final key = _dateToKey(date);
    return _cacheBox.get(key);  // Returns DailyRecapData directly!
  }

  Future<void> _saveToCache(DateTime date, DailyRecapData recap) async {
    final key = _dateToKey(date);
    await _cacheBox.put(key, recap);  // Stores typed object!
  }
 String _dateToKey(DateTime date) {
  return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}


  // Update the generateDailyRecap function

  Future<DailyRecapData> generateDailyRecap({
    required DateTime date,
    required List<SummaryWithContent> summaries,
    bool forceRegenerate = false, // Add this field
  }) async {
    // Check cache first (unless forcing regeneration)
    if (!forceRegenerate) { // Add this if
      final cached = getCachedRecap(date);
      if (cached != null) {
        // ignore: avoid_print
        print('Using cached recap for ${_dateToKey(date)}');
        return cached;
      }
    }

    if (summaries.isEmpty) {
      return DailyRecapData.empty(date);
    }

    // Build context from summaries
    final context = _buildSummariesContext(summaries);

    // Craft the prompt
    final prompt = _buildPrompt(date, context);

    try {
      final response = await _model.generateContent([Content.text(prompt)]);
      // ignore: avoid_print
      print("Gemini response received. ${response.text}");
      final jsonText = response.text ?? '{}';

      // Parse the JSON response
      final data = jsonDecode(jsonText) as Map<String, dynamic>;

      final recap = DailyRecapData.fromJson(date, data);

      // Save to cache Add this as well
      await _saveToCache(date, recap);

      return recap;
    } catch (e) {
      // ignore: avoid_print
      print('Error generating daily recap: $e');
      rethrow; // Throw error so UI can handle it
    }
  }

Results:

✅ App restart: Instant load (no API calls!)

✅ Type safe: No runtime errors

✅ Persistent: Survives app closes

✅ Fast: Disk read vs network call

Updating the app initialization

Now in your main.dart, update the service initialization to call the new initialize() method:

// Replace _intitializeServices()

Future<void> _initializeServices() async {
  setState(() => _loadingState = LoadingState.loading);

  try {
    // Initialize Pieces OS
    await _piecesService.initialize();
    await _piecesService.waitForInitialSync();

    // Initialize Gemini with Hive caching
    const apiKey = String.fromEnvironment('GEMINI_API_KEY');
    if (apiKey.isNotEmpty) {
      _recapService = DailyRecapService(apiKey: apiKey);
      await _recapService!.initialize();  // Initialize Hive
    }

    // Load days and recaps...
    _loadedDays = _piecesService.getDaysWithSummaries();
    await _loadRecapsForVisibleDays();

    setState(() => _loadingState = LoadingState.healthy);
  } catch (e) {
    setState(() => _loadingState = LoadingState.somethingWentWrong);
  }
}

Now, when you restart the app, cached recaps load instantly!


Feature 2: regenerate button

Sometimes you want a fresh recap (maybe you added more work, or the AI messed up). Easy fix - add a refresh button!

// Inside DailyRecapCard's build() method, replace the Date Header section (around line 333)
// Date Header with refresh button
// This is within children[ ... ] stopping at 'const SizedBox(height: 16)'

Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  children: [
    Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(dateFormatter(recap.date)),
        Text(DateFormat('MMMM d, y').format(recap.date)),
      ],
    ),
    IconButton(
      icon: const Icon(Icons.refresh),
      tooltip: 'Regenerate recap',
      onPressed: onRegenerate,  // Callback!
      color: Colors.blue.shade700,
    ),
  ],
)

Small, unobtrusive, but super useful!

The regeneration logic

// Add this method after _loadMore() inside _MyHomePageState class in main.dart

Future<void> _regenerateRecap(DateTime day) async {
  // Mark as regenerating
  setState(() => _regenerating.add(day));

  try {
    final summariesWithContent =
        await _piecesService.getSummariesWithContentForDay(day);

    final recap = await _recapService!.generateDailyRecap(
      date: day,
      summaries: summariesWithContent,
      forceRegenerate: true,  // Bypass cache!
    );

    setState(() {
      _recapsCache[day] = recap;
      _regenerating.remove(day);
    });
  } catch (e) {
    setState(() {
      _errorCache[day] = e.toString();
      _regenerating.remove(day);
    });
  }
}

We track state:

  • Add to _regenerating set → Show loading card

  • Success → Update cache and remove from set

  • Error → Add to error cache


Feature 3: beautiful error handling

When Gemini fails (rate limits, network issues, invalid API key), we need good UX!

The error card

// Add this method after _buildLoadingCard() inside _MyHomePageState class in main.dart

Widget _buildErrorCard(DateTime date, String error) {
  return Card(
    elevation: 4,
    color: Colors.red.shade50,  // Light red background
    child: Padding(
      padding: const EdgeInsets.all(16.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // Header with error icon
          Row(
            children: [
              Icon(Icons.error_outline, color: Colors.red.shade700),
              const SizedBox(width: 8),
              Text(
                dateFormatter(date),
                style: Theme.of(context).textTheme.headlineSmall,
              ),
            ],
          ),

          // Error message
          Card(
            color: Colors.white,
            child: Padding(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                children: [
                  Icon(Icons.warning, color: Colors.orange.shade700),
                  const SizedBox(height: 8),
                  const Text(
                    'Failed to generate recap',
                    style: TextStyle(fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    'There was an issue generating the AI recap for this day.',
                    style: TextStyle(color: Colors.grey.shade700),
                  ),
                  const SizedBox(height: 16),
                  
                  // Try again button!
                  ElevatedButton.icon(
                    onPressed: () => _regenerateRecap(date),
                    icon: const Icon(Icons.refresh),
                    label: const Text('Try Again'),
                    style: ElevatedButton.styleFrom(
                      backgroundColor: Colors.orange,
                      foregroundColor: Colors.white,
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    ),
  );
}

Users see:

❌ Clear error indication (red card)

⚠️ Warning icon

🔄 "Try Again" button (orange, action-oriented)

Much better than a crash or blank card!

State management

We track three states per card:

// No action needed :)

final Map<DateTime, DailyRecapData> _recapsCache = {};  // Success
final Map<DateTime, String> _errorCache = {};           // Error
final Set<DateTime> _regenerating = {};                 // Loading

Then in the UI:

// No action needed :)

children: visibleDays.map((day) {
  final recap = _recapsCache[day];
  final error = _errorCache[day];
  final isRegenerating = _regenerating.contains(day);

  return SizedBox(
    width: 400,
    child: isRegenerating
        ? _buildLoadingCard(day, isRegenerating: true)
        : error != null
            ? _buildErrorCard(day, error)
            : recap != null
                ? DailyRecapCard(recap: recap, onRegenerate: () =>
                  _regenerateRecap(day),
                ))
                : _buildLoadingCard(day),
  );
}).toList(),

Priority:

  1. Is it regenerating? → Show loading with "Regenerating..." text

  2. Is there an error? → Show error card with retry button

  3. Is it loaded? → Show recap card with regenerate button

  4. Otherwise → Show loading card

Clean and predictable!


Feature 4: automated tests

Writing tests for Flutter apps with external services is tricky. Here's what I did:

Start by adding our required dependencies to you pubspec.yaml 

dev_dependencies:
  flutter_test:
    sdk: flutter

Data model tests

Now, let’s test the core data structures. In a new Dart file test/daily_recap_models_test.dart, place our test:

import 'package:flutter_test/flutter_test.dart';
import 'package:blog_walkthrough/models/daily_recap_models.dart';

void main() {
  test('fromJson creates valid DailyRecapData object', () {
    final date = DateTime(2025, 11, 4);
    final json = {
      'summary': 'Test summary',
      'people': ['Alice', 'Bob'],
      'projects': [
        {
          'name': 'Project A',
          'description': 'Test project',
          'status': 'completed',
        }
      ],
      'reminders': ['Test reminder'],
      'notes': ['Test note'],
    };

    final recap = DailyRecapData.fromJson(date, json);

    expect(recap.summary, 'Test summary');
    expect(recap.people, ['Alice', 'Bob']);
    expect(recap.projects.first.status, ProjectStatus.completed);
  });
}

This code tests all the parsing logic!

Project status parsing

import 'package:flutter_test/flutter_test.dart';
import 'package:blog_walkthrough/models/daily_recap_models.dart';

void main() {
  group('ProjectData', () {
    test('fromJson handles all status types', () {
      expect(
        ProjectData.fromJson({
          'name': 'Test Project',
          'description': 'Test description',
          'status': 'completed',
        }).status,
        ProjectStatus.completed,
      );

      expect(
        ProjectData.fromJson({
          'name': 'Test Project',
          'description': 'Test description',
          'status': 'in_progress',
        }).status,
        ProjectStatus.inProgress,
      );

      expect(
        ProjectData.fromJson({
          'name': 'Test Project',
          'description': 'Test description',
          'status': 'invalid',
        }).status,
        ProjectStatus.notStarted,  // Default fallback
      );
    });
  });
}

Logic tests (no external dependencies)

import 'package:flutter_test/flutter_test.dart';

void main() {
  test('date normalization to midnight works correctly', () {
    final date1 = DateTime(2025, 11, 4, 14, 30);  // 2:30 PM
    final date2 = DateTime(2025, 11, 4, 0, 0);    // Midnight

    final normalized1 = DateTime(date1.year, date1.month, date1.day);
    final normalized2 = DateTime(date2.year, date2.month, date2.day);

    expect(normalized1, normalized2);
    expect(normalized1.hour, 0);
  });
}

Skipping integration tests

Tests that need Pieces OS running:

import 'package:flutter_test/flutter_test.dart';
// import 'package:blog_walkthrough/services/pieces_os_service.dart';

void main() {
  group('PiecesOSService', () {
    test('getDaysWithSummaries requires Pieces OS', () {
      // This test requires a running Pieces OS instance
      // It's covered by integration tests
    }, skip: 'Requires Pieces OS connection');
  });
}

Clean and honest!

Running the tests

flutter test

Output:

00:01 +11 ~1: All tests passed

11 tests passed, 1 skipped!


Feature 5: CI/CD with GitHub Actions

Automated testing on every push/PR! Created .github/workflows/test.yml:

name: Run Tests

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.24.0'
          channel: 'stable'

      - name: Install dependencies
        run: flutter pub get

      - name: Verify formatting
        run: dart format --set-exit-if-changed lib test

      - name: Analyze code
        run: flutter analyze

      - name: Run tests
        run: flutter test

  build:
    runs-on: macos-latest
    needs: test

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Flutter
        uses: subosito/flutter-action@v2

      - name: Install dependencies
        run: flutter pub get

      - name: Build macOS app
        run: flutter build macos --release

      - name: Upload macOS artifact
        uses: actions/upload-artifact@v3
        with:
          name: macos-app
          path: build/macos/Build/Products/Release/daily_recap_app.app

What it does:

  1. On every push/PR: Runs automatically

  2. Check formatting: Ensures code style

  3. Analyze: Catches potential issues

  4. Run tests: All 11 tests must pass

  5. Generate coverage: See what's tested

  6. Build macOS app: Verify it compiles

  7. Upload artifact: Downloadable .app file

Now you can't merge broken code!


Running everything

Run tests

flutter test

Generate Hive adapters (if models change)

flutter pub run build_runner build --delete-conflicting-outputs

Run the app

flutter run -d macos --dart-define=GEMINI_API_KEY=your-key

See it in Action

First run:

Connecting to Pieces OS... (5 seconds)
Generating recap with AI... (3 seconds per card)

Close and reopen:

Connecting to Pieces OS... (5 seconds)
Using cached recap for 2025-11-06  Instant!
Using cached recap for 2025-11-05  Instant!
Using cached recap for 2025-11-04  Instant

So much faster!


Final project structure

daily_recap_app/
├── lib/
├── main.dart - UI with error handling & regenerate
├── models/
├── daily_recap_models.dart - Hive models
└── daily_recap_models.g.dart (generated) - Type adapters
└── services/
├── pieces_os_service.dart
└── daily_recap_service.dart - With Hive caching
├── test/
├── daily_recap_service_test.dart - 9 tests
└── pieces_os_service_test.dart - 3 tests
├── .github/
└── workflows/
└── test.yml - CI/CD pipeline
└── pubspec.yaml

If this helped you or you built something cool with it, I'd love to hear about it!

Reference GitHub to view the full project.

Written by

Written by

SHARE

Building a daily productivity app with Pieces — Part 4: polish & production ready

our newsletter

Sign up for The Pieces Post

Check out our monthly newsletter for curated tips & tricks, product updates, industry insights and more.

our newsletter

Sign up for The Pieces Post

Check out our monthly newsletter for curated tips & tricks, product updates, industry insights and more.

our newsletter

Sign up for The Pieces Post

Check out our monthly newsletter for curated tips & tricks, product updates, industry insights and more.