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:
- Every restart regenerates recaps - Wastes API calls and time
- Errors just fail silently - Bad UX
- Can't regenerate a recap - Stuck with what you got
- No tests - Can't confidently make changes
- 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 serializationextends 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
_regeneratingset → 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:
- Is it regenerating? → Show loading with "Regenerating..." text
- Is there an error? → Show error card with retry button
- Is it loaded? → Show recap card with regenerate button
- 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:
- On every push/PR: Runs automatically
- Check formatting: Ensures code style
- Analyze: Catches potential issues
- Run tests: All 11 tests must pass
- Generate coverage: See what's tested
- Build macOS app: Verify it compiles
- 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.
