/

AI & LLM

Dec 4, 2025

Dec 4, 2025

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 sync

  • DailyRecapService - AI-powered insights

Now we need to:

  1. Initialize both services when the app starts

  2. Load recaps for recent days

  3. Display them in beautiful cards

  4. Handle loading states

  5. 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:

  1. Fetch summaries with their content (from annotations)

  2. Send the summaries to Gemini for analysis

  3. Store the summaries in memory (for this session)

  4. 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:

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.

  1. 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.

  1. 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:

  1. Every restart regenerates all recaps - Slow and wastes API calls

  2. Can't refresh a single recap - Stuck with what you got

  3. Error handling could be better - Just console logs

  4. No tests - Hard to maintain with confidence

  5. 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.

Written by

Written by

SHARE

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

Recent

Dec 4, 2025

Dec 4, 2025

Building a daily productivity app with Pieces — Part 2: Adding AI Intelligence with Gemini

Build a daily productivity app with Pieces (Part 2) by adding AI intelligence with Google Gemini, covering architecture, prompts, integrations, and practical tips to ship smarter workflows.

Build a daily productivity app with Pieces (Part 2) by adding AI intelligence with Google Gemini, covering architecture, prompts, integrations, and practical tips to ship smarter workflows.

Dec 1, 2025

Dec 1, 2025

Building daily stand-up generator using Pieces API — Part 1: The SDK overview

Learn how to build a daily stand-up generator using the Pieces API. This first part of the series covers the SDK overview, key capabilities, and how developers can streamline workflow automation with Pieces.

Learn how to build a daily stand-up generator using the Pieces API. This first part of the series covers the SDK overview, key capabilities, and how developers can streamline workflow automation with Pieces.

Nov 27, 2025

Nov 27, 2025

How we stopped watching our engineers struggle through stand-ups

Tired of awkward standup meetings where great engineers sound like they did nothing? I automated our team's standups with AI and got 3 people promoted. Here's exactly how we changed standups and made real work visible to managers.

Tired of awkward standup meetings where great engineers sound like they did nothing? I automated our team's standups with AI and got 3 people promoted. Here's exactly how we changed standups and made real work visible to managers.

our newsletter

Sign up for The Pieces Post

Check out our monthly newsletter for curated tips & tricks, product updates, industry insights and more.

our newsletter

Sign up for The Pieces Post

Check out our monthly newsletter for curated tips & tricks, product updates, industry insights and more.

our newsletter

Sign up for The Pieces Post

Check out our monthly newsletter for curated tips & tricks, product updates, industry insights and more.