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:
import 'package:hive/hive.dart';
part 'daily_recap_models.g.dart';
@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,
});
}
@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,
});
}
@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:
import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';
class DailyRecapService {
final GenerativeModel _model;
late final Box<DailyRecapData> _cacheBox;
DailyRecapService({required String apiKey})
: _model = GenerativeModel(
model: 'gemini-2.5-pro',
apiKey: apiKey,
);
Future<void> initialize() async {
await Hive.initFlutter();
if (!Hive.isAdapterRegistered(0)) {
Hive.registerAdapter(DailyRecapDataAdapter());
}
if (!Hive.isAdapterRegistered(1)) {
Hive.registerAdapter(ProjectDataAdapter());
}
if (!Hive.isAdapterRegistered(2)) {
Hive.registerAdapter(ProjectStatusAdapter());
}
_cacheBox = await Hive.openBox<DailyRecapData>('daily_recaps_cache');
}
DailyRecapData? getCachedRecap(DateTime date) {
final key = _dateToKey(date);
return _cacheBox.get(key);
}
Future<void> _saveToCache(DateTime date, DailyRecapData recap) async {
final key = _dateToKey(date);
await _cacheBox.put(key, recap);
}
String _dateToKey(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
Future<DailyRecapData> generateDailyRecap({
required DateTime date,
required List<SummaryWithContent> summaries,
bool forceRegenerate = false,
}) async {
if (!forceRegenerate) {
final cached = getCachedRecap(date);
if (cached != null) {
print('Using cached recap for ${_dateToKey(date)}');
return cached;
}
}
if (summaries.isEmpty) {
return DailyRecapData.empty(date);
}
final context = _buildSummariesContext(summaries);
final prompt = _buildPrompt(date, context);
try {
final response = await _model.generateContent([Content.text(prompt)]);
print("Gemini response received. ${response.text}");
final jsonText = response.text ?? '{}';
final data = jsonDecode(jsonText) as Map<String, dynamic>;
final recap = DailyRecapData.fromJson(date, data);
await _saveToCache(date, recap);
return recap;
} catch (e) {
print('Error generating daily recap: $e');
rethrow;
}
}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:
Future<void> _initializeServices() async {
setState(() => _loadingState = LoadingState.loading);
try {
await _piecesService.initialize();
await _piecesService.waitForInitialSync();
const apiKey = String.fromEnvironment('GEMINI_API_KEY');
if (apiKey.isNotEmpty) {
_recapService = DailyRecapService(apiKey: apiKey);
await _recapService!.initialize();
}
_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!
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,
color: Colors.blue.shade700,
),
],
)Small, unobtrusive, but super useful!
The regeneration logic
Future<void> _regenerateRecap(DateTime day) async {
setState(() => _regenerating.add(day));
try {
final summariesWithContent =
await _piecesService.getSummariesWithContentForDay(day);
final recap = await _recapService!.generateDailyRecap(
date: day,
summaries: summariesWithContent,
forceRegenerate: true,
);
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
Widget _buildErrorCard(DateTime date, String error) {
return Card(
elevation: 4,
color: Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade700),
const SizedBox(width: 8),
Text(
dateFormatter(date),
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
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),
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:
final Map<DateTime, DailyRecapData> _recapsCache = {};
final Map<DateTime, String> _errorCache = {};
final Set<DateTime> _regenerating = {}; Then in the UI:
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: flutterData 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,
);
});
});
}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);
final date2 = DateTime(2025, 11, 4, 0, 0);
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';
void main() {
group('PiecesOSService', () {
test('getDaysWithSummaries requires Pieces OS', () {
}, skip: 'Requires Pieces OS connection');
});
}Clean and honest!
Running the tests
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.appWhat 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
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.