← All posts
AI & LLM

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.

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

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.