Building a daily productivity app with Pieces - Part 3: bringing it all together with Flutter UI
Part 3: Flutter UI to bring your Pieces daily productivity app together.

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 syncDailyRecapService- 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:
// Place MyHomePage class after MyApp class
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; // Show 3 cards initially
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:
// Add this method after initState() inside _MyHomePageState class
Future<void> _initializeServices() async {
setState(() => _loadingState = LoadingState.loading);
try {
try {
// Step 1: Initialize Pieces OS
await _piecesService.initialize();
} catch (e) {
setState(() {
_loadingState = LoadingState.piecesOsNotRunning;
});
return;
}
// Step 2: Wait for summaries to load (WebSocket takes time!)
await _piecesService.waitForInitialSync();
// Step 3: Check for Gemini API key
const apiKey = String.fromEnvironment('GEMINI_API_KEY');
if (apiKey == '') {
setState(() {
_loadingState = LoadingState.geminiApiKeyMissing;
});
return;
}
// Step 4: Initialize Gemini service
if (apiKey.isNotEmpty) {
_recapService = DailyRecapService(apiKey: apiKey);
}
// Step 5: Get days with data
_loadedDays = _piecesService.getDaysWithSummaries();
// Step 6: Load recaps for first 3 days
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.
// Add this method after _initializeServices() inside _MyHomePageState class
// (the one you just made)
Future<void> _loadRecapsForVisibleDays() async {
if (_recapService == null) return;
final daysToLoad = _loadedDays.take(_visibleCards).toList();
for (final day in daysToLoad) {
if (!_recapsCache.containsKey(day)) {
try {
// Fetch summaries with content
final summariesWithContent = await getSummariesWithContentForDay(
_piecesService, day);
if (summariesWithContent.isNotEmpty) {
// Generate recap with Gemini
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:
// Add this method after _loadRecapsForVisibleDays() inside _MyHomePageState
class
Widget _buildRecapsList() {
final visibleDays = _loadedDays.take(_visibleCards).toList();
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Cards flow horizontally, wrap to next row when overflow
Wrap(
spacing: 16, // Horizontal spacing between cards
runSpacing: 16, // Vertical spacing between rows
children: visibleDays.map((day) {
final recap = _recapsCache[day];
return SizedBox(
width: 400, // Fixed width for each card
child: recap != null
? DailyRecapCard(recap: recap)
: _buildLoadingCard(day),
);
}).toList(),
),
// Load More button centered
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:
// Add this method after _buildRecapsList() inside _MyHomePageState class
Future<void> _loadMore() async {
setState(() => _isLoadingMore = true);
// Increase visible count
setState(() => _visibleCards += 3);
// Load the new days
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
// Place this at the top under the MyApp class
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);
}
}
// Add this widget class after _MyHomePageState class closes
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: [
// Date Header
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),
// Daily Summary with icon
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)),
],
),
),
),
// ... rest of the sections
],
),
),
);
}
}
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});
// Add this helper method inside DailyRecapCard class
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(),
// Status badge
Container(...status badge...),
],
),
Text(project.description,
style: TextStyle(color: Colors.grey)),
],
),
),
],
),
);
}).toList();
}
@override
Widget build(BuildContext context) {
// ... existing build method ...
}
}
Reminders & notes
Next, let’s add a reminders and notes widget!
// ... rest of the sections
// Reminders section
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:
// Add this helper method after _buildProjectWidgets() inside DailyRecapCard class
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:
// Add this helper method before _buildProjectWidgets() inside DailyRecapCard class
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:
// Notes section - add after reminders section
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:
// Add this build() method after _loadMore() inside _MyHomePageState class
@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!
// Add this method after _buildRecapsList() inside _MyHomePageState class
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!
// Add this method after _buildLoadingScreen() inside _MyHomePageState class
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:
// Add this method after _buildRecapsList() inside _MyHomePageState class
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:
- Find these two files in your project:
- macos/Runner/DebugProfile.entitlements
- macos/Runner/Release.entitlements
- 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.
