Welcome to Part 3! In Part 1, we built real-time sync with Pieces OS. In Part 2, we added Gemini AI to extract insights. Now it's time to make it beautiful with a Flutter UI! 🎨
What we're building
A clean Flutter app that shows:
📅 Daily recap cards
💼 Projects with status indicators
👥 People you collaborated with (with avatars!)
⏰ Reminders
📝 Notes and learnings
🔄 "Load More" pagination
The challenge
We have two services working perfectly:
PiecesOSService - Real-time data sync
DailyRecapService - AI-powered insights
Now we need to:
Initialize both services when the app starts
Load recaps for recent days
Display them in beautiful cards
Handle loading states
Allow pagination ("Load More")
Setting up
First, add the avatar package to pubspec.yaml:
dependencies:
flutter_initicon: ^2.0.0 # For generating avatars from names
This package creates beautiful avatars from initials - perfect for showing collaborators!
Integrating the services
Start by removing anything that may be in your main.dart file and replace it with:
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:timeago/timeago.dart' as timeago;
import 'package:flutter_initicon/flutter_initicon.dart';
import 'services/pieces_os_service.dart';
import 'services/daily_recap_service.dart';
import 'models/daily_recap_models.dart';
enum LoadingState {
loading,
healthy,
piecesOsNotRunning,
geminiApiKeyMissing,
somethingWentWrong,
}
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Daily Recap Dashboard',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Daily Recap Dashboard'),
);
}
}This sets up a Flutter app that displays a Daily Recap Dashboard with Material Design theming. Then we’re going to add two more classes to main.dart:
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final PiecesOSService _piecesService = PiecesOSService();
DailyRecapService? _recapService;
List<DateTime> _loadedDays = [];
final Map<DateTime, DailyRecapData> _recapsCache = {};
LoadingState _loadingState = LoadingState.loading;
bool _isLoadingMore = false;
int _visibleCards = 3;
final Set<DateTime> _regenerating = {};
final Map<DateTime, String> _errorCache = {};
@override
void initState() {
super.initState();
_initializeServices();
}
}We use a LoadingState enum to track different states - loading, healthy (success), or various error conditions. This gives us better control over the UI.
The initialization dance
This is where it all comes together:
Future<void> _initializeServices() async {
setState(() => _loadingState = LoadingState.loading);
try {
try {
await _piecesService.initialize();
} catch (e) {
setState(() {
_loadingState = LoadingState.piecesOsNotRunning;
});
return;
}
await _piecesService.waitForInitialSync();
const apiKey = String.fromEnvironment('GEMINI_API_KEY');
if (apiKey == '') {
setState(() {
_loadingState = LoadingState.geminiApiKeyMissing;
});
return;
}
if (apiKey.isNotEmpty) {
_recapService = DailyRecapService(apiKey: apiKey);
}
_loadedDays = _piecesService.getDaysWithSummaries();
await _loadRecapsForVisibleDays();
setState(() => _loadingState = LoadingState.healthy);
} catch (e) {
print('Error initializing: $e');
setState(() => _loadingState = LoadingState.somethingWentWrong);
}
}This initializes the Pieces OS service, waits for summaries to sync, checks for a Gemini API key, creates the recap service, and loads the first 3 days of recaps, updating the loading state accordingly.
Loading recaps
For each visible day, we:
Fetch summaries with their content (from annotations)
Send the summaries to Gemini for analysis
Store the summaries in memory (for this session)
Update the UI
We've connected to Pieces OS, waited for summaries to sync, checked for a Gemini API key, and collected all days that have summaries. We know which days have data, but we haven't generated any recaps yet.
Future<void> _loadRecapsForVisibleDays() async {
if (_recapService == null) return;
final daysToLoad = _loadedDays.take(_visibleCards).toList();
for (final day in daysToLoad) {
if (!_recapsCache.containsKey(day)) {
try {
final summariesWithContent = await getSummariesWithContentForDay(
_piecesService, day);
if (summariesWithContent.isNotEmpty) {
final recap = await _recapService!.generateDailyRecap(
date: day,
summaries: summariesWithContent,
);
setState(() {
_recapsCache[day] = recap;
});
}
} catch (e) {
print('Error loading recap for $day: $e');
}
}
}
}Notice we store recaps in memory (_recapsCache) - so we only call Gemini once per day during this session. When you restart the app, it will regenerate everything. (We'll fix that with persistent caching in Part 4!)
The wrap layout — responsive cards
Here's where the UI shines! Cards flow horizontally and wrap to the next row when they don't fit:
class
Widget _buildRecapsList() {
final visibleDays = _loadedDays.take(_visibleCards).toList();
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Wrap(
spacing: 16,
runSpacing: 16,
children: visibleDays.map((day) {
final recap = _recapsCache[day];
return SizedBox(
width: 400,
child: recap != null
? DailyRecapCard(recap: recap)
: _buildLoadingCard(day),
);
}).toList(),
),
if (visibleDays.length < _loadedDays.length)
Center(
child: ElevatedButton.icon(
onPressed: _loadMore,
icon: const Icon(Icons.expand_more),
label: const Text('Load More Days'),
),
),
],
),
);
}The Wrap widget is perfect for this! It:
Places cards horizontally first (left to right)
When a card doesn't fit, wraps to the next row
Handles different screen sizes automatically
On a typical desktop, you might see 3 cards in the first row, then more rows below as you load more data. On a laptop, maybe 2 per row. It just works!
The "Load more" feature
Let’s build the iconic “load more” button in our UI:
Future<void> _loadMore() async {
setState(() => _isLoadingMore = true);
setState(() => _visibleCards += 3);
await _loadRecapsForVisibleDays();
setState(() => _isLoadingMore = false);
}Only show the button if there are more days to load! When clicked, it adds 3 more cards, which flow into the wrap layout naturally.
Building the Daily Recap Card
Here's where the magic happens. We take the DailyRecapData from our service and transform it into a beautiful card:
Let’s add a formatter function to format the date
String dateFormatter(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return 'Today';
} else if (difference.inDays == 1) {
return 'Yesterday';
} else {
return timeago.format(date);
}
}
class DailyRecapCard extends StatelessWidget {
final DailyRecapData recap;
const DailyRecapCard({super.key, required this.recap});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dateFormatter(recap.date),
style: Theme.of(context).textTheme.headlineSmall,
),
Text(
DateFormat('MMMM d, y').format(recap.date),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: Colors.grey.shade600),
),
const SizedBox(height: 16),
Card(
color: Colors.blue.shade50,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(Icons.summarize, color: Colors.blue.shade700),
const SizedBox(width: 12),
Expanded(child: Text(recap.summary)),
],
),
),
),
],
),
),
);
}
}The card we just added is responsive and shows different sections based on what data is available.
Clean bullet-point style
For readability, I kept it simple - no nested cards, just clean bullet points:
\\ class DailyRecapCard extends StatelessWidget {
\\ final DailyRecapData recap;
\\ const DailyRecapCard({super.key, required this.recap});
List<Widget> _buildProjectWidgets(List<ProjectData> projects) {
return projects.map((project) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Icon(statusIcon, color: statusColor, size: 20),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(project.name,
style: TextStyle(fontWeight: FontWeight.bold)),
Spacer(),
Container(...status badge...),
],
),
Text(project.description,
style: TextStyle(color: Colors.grey)),
],
),
),
],
),
);
}).toList();
}
@override
Widget build(BuildContext context) {
}
}Reminders & notes
Next, let’s add a reminders and notes widget!
if (recap.reminders.isNotEmpty) ...[
const SizedBox(height: 16),
Text("Reminders", style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...recap.reminders.map((reminder) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
color: Colors.amber.shade50,
child: ListTile(
leading: Icon(Icons.alarm, color: Colors.amber.shade700),
title: Text(reminder),
),
);
}),
],No nested cards, no complexity. Just clean, readable bullet points with icons!
People avatars with initicon
This is one of my favorite touches - generating avatars from names:
List<Widget> _buildPeopleWidgets(List<String> people) {
return people.map((person) {
return Tooltip(
message: person,
child: Initicon(
text: person,
size: 40,
backgroundColor: Colors.primaries[
person.hashCode % Colors.primaries.length
],
),
);
}).toList();
}Each person gets a unique color based on their name's hash. Looks great and no need for actual images!
Project status indicators
Projects show their status with icons and colored badges:
Widget _buildProjectWidget(ProjectData project) {
Color statusColor;
String statusText;
IconData statusIcon;
switch (project.status) {
case ProjectStatus.completed:
statusColor = Colors.green;
statusText = "Completed";
statusIcon = Icons.check_circle;
break;
case ProjectStatus.inProgress:
statusColor = Colors.orange;
statusText = "In Progress";
statusIcon = Icons.sync;
break;
case ProjectStatus.notStarted:
statusColor = Colors.red;
statusText = "Not Started";
statusIcon = Icons.radio_button_unchecked;
break;
}
return Card(
child: ListTile(
leading: Icon(statusIcon, color: statusColor),
title: Text(project.name,
style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(project.description),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: statusColor),
),
child: Text(
statusText,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
Notes sections — the new addition
Let’s add notes and reminders section to show important learnings from the day:
if (recap.notes.isNotEmpty) ...[
const SizedBox(height: 16),
Text("Notes", style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...recap.notes.map((note) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
color: Colors.purple.shade50,
child: ListTile(
leading: Icon(Icons.lightbulb, color: Colors.purple.shade700),
title: Text(note),
),
);
}),
],
Handling loading states
Good UX means showing what's happening. We use the LoadingState enum to render different screens:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.title)),
body: _loadingState == LoadingState.loading
? _buildLoadingScreen()
: _loadingState == LoadingState.healthy
? _buildRecapsList()
: _buildErrorScreen(),
);
}
The loading screen
Now, let’s build a loading screen!
Widget _buildLoadingScreen() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 20),
Text('Connecting to Pieces OS...'),
SizedBox(height: 10),
Text('This may take a few seconds'),
],
),
);
}The error screen
Inevitably, we’ll hit some errors. Let’s build a screen for that!
Widget _buildErrorScreen() {
String message;
IconData icon;
switch (_loadingState) {
case LoadingState.piecesOsNotRunning:
message = 'Pieces OS is not running. Please start Pieces OS and try again.';
icon = Icons.cloud_off;
break;
case LoadingState.geminiApiKeyMissing:
message = 'Gemini API key is missing. Please set the API key and restart the app.';
icon = Icons.vpn_key_off;
break;
case LoadingState.somethingWentWrong:
default:
message = 'Something went wrong. Please try again later.';
icon = Icons.error_outline;
break;
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: Colors.red),
const SizedBox(height: 20),
Text(
message,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
],
),
);
}Now users get specific, actionable error messages instead of generic failures!
Loading individual cards
Next, let’s get the cards loading in individually:
Widget _buildLoadingCard(DateTime date) {
return Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Text(DateFormat('EEEE, MMMM d').format(date)),
const SizedBox(height: 10),
const CircularProgressIndicator(),
const SizedBox(height: 8),
const Text('Generating recap with AI...'),
],
),
),
);
}This keeps the users in the loop, so they know exactly what’s going on!
The macOS network permission issue
MacOS apps are "sandboxed" by default - think of it like a security fence that blocks your app from accessing the internet or other apps on your computer. This includes connecting to localhost (your own computer).
When you try to connect to Pieces OS at `localhost:39300`, macOS blocks it because your app doesn't have permission to make network connections.
You can fix this by:
1. Find these two files in your project:
- macos/Runner/DebugProfile.entitlements
- macos/Runner/Release.entitlements
2. Open each file and add these two lines inside the <dict> section:
<key>com.apple.security.network.client</key>
<true
3. Save both files.
That's it! Your app can now connect to Pieces OS and other network services.
Running our application
Which sets up your workspace for your device, then you can run:
flutter run -d macos --dart-define=GEMINI_API_KEY=your-key-here
And boom! You get:
On Startup:
Connecting to Pieces OS...
This may take a few seconds
After Loading:
3 beautiful daily recap cards
Cards flow horizontally, wrap to next row if needed
Each card 400px wide, responsive to screen size
Each showing summary, projects, people, reminders, notes
"Load More Days" button at the bottom
When You Click "Load More Days":
Shows loading spinner
Fetches 3 more days
Generates their recaps with Gemini
Adds them to the wrap layout
Seamless expansion!
Missing icons?
If you find that the icons (like Icons.summarize, Icons.alarm, Icons.lightbulb, etc.) are not displaying correctly in your Flutter application, it usually means the font that provides these icons (Material Icons) is not being bundled or referenced correctly, or a specific dependency is missing an implicit requirement.
Open pubspec.yaml.
Ensure the uses-material-design: true line is present and uncommented under the flutter: section. This line explicitly tells Flutter to include the Material Icons font in your application bundle.
# Inside pubspec.yaml
flutter:
uses-material-design: true
Run flutter pub get in your terminal after confirming or adding the line to fetch dependencies and update the project configuration.
Restart the Application: If the app was already running, you must stop and restart it (not just hot reload or hot restart) to load the new resource configuration.
What we've built
At this point, you have a fully functional productivity that:
✅ Syncs with Pieces OS in real-time
✅ Uses AI to generate daily insights
✅ Displays beautiful, responsive cards
✅ Handles loading and error states
✅ Supports pagination with "Load More"
✅ Shows projects, people, reminders, and notes
But there's room for improvement...
Right now, there are a few limitations:
Every restart regenerates all recaps - Slow and wastes API calls
Can't refresh a single recap - Stuck with what you got
Error handling could be better - Just console logs
No tests - Hard to maintain with confidence
No CI/CD - Manual testing every time
In Part 4, we'll tackle all of these! We'll add:
💾 Persistent caching with Hive - Instant loads on restart
🔄 Regenerate button - Refresh any recap on demand
❌ Better error handling - Beautiful error cards with retry
✅ Automated tests - Catch bugs before they ship
🚀 CI/CD pipeline - Automated testing and builds
See you in Part 4! 🚀
Reference GitHub to view the full project.