image

19 Feb 2026

9K

35K

Building a Goal Tracker Widget with Milestone Indicator in Flutter

Tracking progress is a fundamental aspect of achieving goals, whether personal or professional. A visual representation of progress, especially one that highlights key milestones, can significantly boost motivation and provide clarity on the journey ahead. In Flutter, we can create highly customizable and engaging UI components to serve this purpose. This article will guide you through building a "Goal Tracker Widget with Milestone Indicator" in Flutter, leveraging custom painting to craft a unique visual experience.

Understanding the Core Components

Our goal tracker widget will consist of several key parts:

  • Goal Data Model: To define a goal, its current progress, and associated milestones.
  • Goal Tracker Widget: A stateful or stateless widget that encapsulates the entire UI for a single goal.
  • Milestone Indicator: The visual component that displays a timeline with marked milestones and indicates the current progress point. This will be built using Flutter's CustomPainter.

1. Designing the Data Models

First, let's define the data structures for our goals and milestones. These will be simple Dart classes.

Milestone Model

Each milestone will have a name, a target progress percentage, and a completion status.


class Milestone {
  final String name;
  final double targetProgress; // A value between 0.0 and 1.0
  final bool isCompleted;

  Milestone({required this.name, required this.targetProgress, this.isCompleted = false});
}

Goal Model

The goal will hold its title, the current overall progress, and a list of its milestones.


class Goal {
  final String title;
  final double currentProgress; // A value between 0.0 and 1.0
  final List<Milestone> milestones;

  Goal({required this.title, required this.currentProgress, required this.milestones});
}

2. Building the Milestone Indicator with CustomPainter

The heart of our widget is the milestone indicator, which will draw a timeline, mark milestones, and show current progress. We'll achieve this using CustomPainter, which provides a canvas to draw directly onto.

MilestoneIndicatorPainter Class

This class will extend CustomPainter and implement the drawing logic.


import 'package:flutter/material.dart';

class MilestoneIndicatorPainter extends CustomPainter {
  final List<Milestone> milestones;
  final double currentProgress; // 0.0 to 1.0
  final Color progressColor;
  final Color milestoneColor;
  final Color lineColor;
  final double lineWidth;
  final double milestoneRadius;

  MilestoneIndicatorPainter({
    required this.milestones,
    required this.currentProgress,
    this.progressColor = Colors.blue,
    this.milestoneColor = Colors.grey,
    this.lineColor = Colors.grey,
    this.lineWidth = 4.0,
    this.milestoneRadius = 8.0,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final double timelineWidth = size.width;
    final double timelineHeight = size.height / 2; // Roughly center the line vertically

    // 1. Draw the main timeline background line
    final Paint backgroundLinePaint = Paint()
      ..color = lineColor.withOpacity(0.5)
      ..strokeWidth = lineWidth
      ..strokeCap = StrokeCap.round
      ..style = PaintingStyle.stroke;

    canvas.drawLine(
      Offset(0, timelineHeight),
      Offset(timelineWidth, timelineHeight),
      backgroundLinePaint,
    );

    // 2. Draw the progress line
    final Paint progressLinePaint = Paint()
      ..color = progressColor
      ..strokeWidth = lineWidth
      ..strokeCap = StrokeCap.round
      ..style = PaintingStyle.stroke;

    final double currentProgressX = timelineWidth * currentProgress;
    canvas.drawLine(
      Offset(0, timelineHeight),
      Offset(currentProgressX, timelineHeight),
      progressLinePaint,
    );

    // 3. Draw milestones and labels
    for (Milestone milestone in milestones) {
      final double milestoneX = timelineWidth * milestone.targetProgress;

      // Draw milestone circle
      final Paint milestoneCirclePaint = Paint()
        ..color = milestone.isCompleted ? progressColor : milestoneColor
        ..style = PaintingStyle.fill;
      
      canvas.drawCircle(Offset(milestoneX, timelineHeight), milestoneRadius, milestoneCirclePaint);

      // Draw milestone border if not completed
      if (!milestone.isCompleted) {
        final Paint borderPaint = Paint()
          ..color = milestoneColor.withOpacity(0.8)
          ..strokeWidth = 2.0
          ..style = PaintingStyle.stroke;
        canvas.drawCircle(Offset(milestoneX, timelineHeight), milestoneRadius, borderPaint);
      }

      // Draw milestone label
      final TextPainter textPainter = TextPainter(
        text: TextSpan(
          text: milestone.name,
          style: TextStyle(
            color: milestone.isCompleted ? progressColor.darken(0.2) : Colors.black87,
            fontSize: 12,
            fontWeight: FontWeight.bold,
          ),
        ),
        textDirection: TextDirection.ltr,
      );
      textPainter.layout(minWidth: 0, maxWidth: 100); // Adjust maxWidth as needed

      // Position text above the milestone
      textPainter.paint(
        canvas,
        Offset(milestoneX - textPainter.width / 2, timelineHeight - milestoneRadius - textPainter.height - 5),
      );
    }

    // 4. Draw current progress indicator (a larger circle or a custom shape)
    final Paint currentProgressIndicatorPaint = Paint()
      ..color = progressColor
      ..style = PaintingStyle.fill;
    canvas.drawCircle(Offset(currentProgressX, timelineHeight), milestoneRadius + 4, currentProgressIndicatorPaint);

    final Paint currentProgressIndicatorBorderPaint = Paint()
      ..color = Colors.white // A white border to make it pop
      ..strokeWidth = 2.0
      ..style = PaintingStyle.stroke;
    canvas.drawCircle(Offset(currentProgressX, timelineHeight), milestoneRadius + 4, currentProgressIndicatorBorderPaint);
  }

  @override
  bool shouldRepaint(covariant MilestoneIndicatorPainter oldDelegate) {
    // Only repaint if the progress or milestones have changed
    return oldDelegate.currentProgress != currentProgress ||
        oldDelegate.milestones.length != milestones.length ||
        !listEquals(oldDelegate.milestones, milestones);
  }

  bool listEquals(List<Milestone> a, List<Milestone> b) {
    if (a.length != b.length) return false;
    for (int i = 0; i < a.length; i++) {
      if (a[i].name != b[i].name || a[i].targetProgress != b[i].targetProgress || a[i].isCompleted != b[i].isCompleted) {
        return false;
      }
    }
    return true;
  }
}

// Helper extension for color manipulation (optional)
extension ColorExtension on Color {
  Color darken([double amount = .1]) {
    assert(amount >= 0 && amount <= 1);
    final hsl = HSLColor.fromColor(this);
    final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0));
    return hslDark.toColor();
  }
}

A few notes on the painter:

  • We draw a background line first, then an overlaying progress line up to currentProgress.
  • Milestones are drawn as circles. Their color changes if they are completed.
  • Text labels for milestones are drawn above the circles.
  • A larger circle indicates the current progress point.
  • The shouldRepaint method is optimized to only repaint when necessary, improving performance.

3. Creating the Goal Tracker Widget

Now, let's assemble the full GoalTrackerWidget that integrates our MilestoneIndicatorPainter.

GoalTrackerWidget Class


import 'package:flutter/material.dart';
// Assuming Milestone and Goal classes are in models.dart or similar
// import 'package:your_app/models.dart';
// Assuming MilestoneIndicatorPainter is in milestone_indicator_painter.dart or similar
// import 'package:your_app/milestone_indicator_painter.dart';

// Re-define for standalone example:
class Milestone {
  final String name;
  final double targetProgress;
  final bool isCompleted;
  Milestone({required this.name, required this.targetProgress, this.isCompleted = false});
}

class Goal {
  final String title;
  final double currentProgress;
  final List<Milestone> milestones;
  Goal({required this.title, required this.currentProgress, required this.milestones});
}
// End re-define

class GoalTrackerWidget extends StatelessWidget {
  final Goal goal;
  final Color progressColor;
  final Color milestoneColor;
  final Color lineColor;

  GoalTrackerWidget({
    required this.goal,
    this.progressColor = Colors.blue,
    this.milestoneColor = Colors.grey,
    this.lineColor = Colors.grey,
  });

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: EdgeInsets.all(16.0),
      elevation: 4.0,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
      child: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(
              goal.title,
              style: TextStyle(
                fontSize: 22,
                fontWeight: FontWeight.bold,
                color: Colors.black87,
              ),
            ),
            SizedBox(height: 10),
            Text(
              'Progress: ${(goal.currentProgress * 100).toStringAsFixed(0)}%',
              style: TextStyle(
                fontSize: 16,
                color: Colors.black54,
              ),
            ),
            SizedBox(height: 25),
            // CustomPaint widget to render our milestone indicator
            Container(
              height: 120, // Give it enough height for the line, milestones, and text
              width: double.infinity,
              child: CustomPaint(
                painter: MilestoneIndicatorPainter(
                  milestones: goal.milestones,
                  currentProgress: goal.currentProgress,
                  progressColor: progressColor,
                  milestoneColor: milestoneColor,
                  lineColor: lineColor,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

4. Integrating into a Flutter Application

To see our widget in action, let's create a simple Flutter app and provide some sample goal data.

main.dart


import 'package:flutter/material.dart';
// Assuming your GoalTrackerWidget is in goal_tracker_widget.dart
// import 'package:your_app/goal_tracker_widget.dart';
// And your MilestoneIndicatorPainter is in milestone_indicator_painter.dart
// import 'package:your_app/milestone_indicator_painter.dart';

// Re-define all necessary classes here for a single file example
// If you've separated them, make sure to import them.
// Start of re-definitions for example
class Milestone {
  final String name;
  final double targetProgress;
  final bool isCompleted;
  Milestone({required this.name, required this.targetProgress, this.isCompleted = false});
}

class Goal {
  final String title;
  final double currentProgress;
  final List<Milestone> milestones;
  Goal({required this.title, required this.currentProgress, required this.milestones});
}

class MilestoneIndicatorPainter extends CustomPainter {
  final List<Milestone> milestones;
  final double currentProgress;
  final Color progressColor;
  final Color milestoneColor;
  final Color lineColor;
  final double lineWidth;
  final double milestoneRadius;

  MilestoneIndicatorPainter({
    required this.milestones,
    required this.currentProgress,
    this.progressColor = Colors.blue,
    this.milestoneColor = Colors.grey,
    this.lineColor = Colors.grey,
    this.lineWidth = 4.0,
    this.milestoneRadius = 8.0,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final double timelineWidth = size.width;
    final double timelineHeight = size.height / 2;

    final Paint backgroundLinePaint = Paint()
      ..color = lineColor.withOpacity(0.5)
      ..strokeWidth = lineWidth
      ..strokeCap = StrokeCap.round
      ..style = PaintingStyle.stroke;

    canvas.drawLine(
      Offset(0, timelineHeight),
      Offset(timelineWidth, timelineHeight),
      backgroundLinePaint,
    );

    final Paint progressLinePaint = Paint()
      ..color = progressColor
      ..strokeWidth = lineWidth
      ..strokeCap = StrokeCap.round
      ..style = PaintingStyle.stroke;

    final double currentProgressX = timelineWidth * currentProgress;
    canvas.drawLine(
      Offset(0, timelineHeight),
      Offset(currentProgressX, timelineHeight),
      progressLinePaint,
    );

    for (Milestone milestone in milestones) {
      final double milestoneX = timelineWidth * milestone.targetProgress;

      final Paint milestoneCirclePaint = Paint()
        ..color = milestone.isCompleted ? progressColor : milestoneColor
        ..style = PaintingStyle.fill;
      
      canvas.drawCircle(Offset(milestoneX, timelineHeight), milestoneRadius, milestoneCirclePaint);

      if (!milestone.isCompleted) {
        final Paint borderPaint = Paint()
          ..color = milestoneColor.withOpacity(0.8)
          ..strokeWidth = 2.0
          ..style = PaintingStyle.stroke;
        canvas.drawCircle(Offset(milestoneX, timelineHeight), milestoneRadius, borderPaint);
      }

      final TextPainter textPainter = TextPainter(
        text: TextSpan(
          text: milestone.name,
          style: TextStyle(
            color: milestone.isCompleted ? progressColor.darken(0.2) : Colors.black87,
            fontSize: 12,
            fontWeight: FontWeight.bold,
          ),
        ),
        textDirection: TextDirection.ltr,
      );
      textPainter.layout(minWidth: 0, maxWidth: 100);

      textPainter.paint(
        canvas,
        Offset(milestoneX - textPainter.width / 2, timelineHeight - milestoneRadius - textPainter.height - 5),
      );
    }

    final Paint currentProgressIndicatorPaint = Paint()
      ..color = progressColor
      ..style = PaintingStyle.fill;
    canvas.drawCircle(Offset(currentProgressX, timelineHeight), milestoneRadius + 4, currentProgressIndicatorPaint);

    final Paint currentProgressIndicatorBorderPaint = Paint()
      ..color = Colors.white
      ..strokeWidth = 2.0
      ..style = PaintingStyle.stroke;
    canvas.drawCircle(Offset(currentProgressX, timelineHeight), milestoneRadius + 4, currentProgressIndicatorBorderPaint);
  }

  @override
  bool shouldRepaint(covariant MilestoneIndicatorPainter oldDelegate) {
    return oldDelegate.currentProgress != currentProgress ||
        oldDelegate.milestones.length != milestones.length ||
        !listEquals(oldDelegate.milestones, milestones);
  }

  bool listEquals(List<Milestone> a, List<Milestone> b) {
    if (a.length != b.length) return false;
    for (int i = 0; i < a.length; i++) {
      if (a[i].name != b[i].name || a[i].targetProgress != b[i].targetProgress || a[i].isCompleted != b[i].isCompleted) {
        return false;
      }
    }
    return true;
  }
}

extension ColorExtension on Color {
  Color darken([double amount = .1]) {
    assert(amount >= 0 && amount <= 1);
    final hsl = HSLColor.fromColor(this);
    final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0));
    return hslDark.toColor();
  }
}


class GoalTrackerWidget extends StatelessWidget {
  final Goal goal;
  final Color progressColor;
  final Color milestoneColor;
  final Color lineColor;

  GoalTrackerWidget({
    required this.goal,
    this.progressColor = Colors.blue,
    this.milestoneColor = Colors.grey,
    this.lineColor = Colors.grey,
  });

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: EdgeInsets.all(16.0),
      elevation: 4.0,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
      child: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(
              goal.title,
              style: TextStyle(
                fontSize: 22,
                fontWeight: FontWeight.bold,
                color: Colors.black87,
              ),
            ),
            SizedBox(height: 10),
            Text(
              'Progress: ${(goal.currentProgress * 100).toStringAsFixed(0)}%',
              style: TextStyle(
                fontSize: 16,
                color: Colors.black54,
              ),
            ),
            SizedBox(height: 25),
            Container(
              height: 120,
              width: double.infinity,
              child: CustomPaint(
                painter: MilestoneIndicatorPainter(
                  milestones: goal.milestones,
                  currentProgress: goal.currentProgress,
                  progressColor: progressColor,
                  milestoneColor: milestoneColor,
                  lineColor: lineColor,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
// End of re-definitions for example

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Goal Tracker Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.teal,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: GoalListPage(),
    );
  }
}

class GoalListPage extends StatefulWidget {
  @override
  _GoalListPageState createState() => _GoalListPageState();
}

class _GoalListPageState extends State<GoalListPage> {
  List<Goal> _goals = [
    Goal(
      title: 'Finish Flutter Project',
      currentProgress: 0.65,
      milestones: [
        Milestone(name: 'Setup', targetProgress: 0.1, isCompleted: true),
        Milestone(name: 'UI Design', targetProgress: 0.3, isCompleted: true),
        Milestone(name: 'API Int.', targetProgress: 0.5, isCompleted: true),
        Milestone(name: 'Logic', targetProgress: 0.7, isCompleted: false),
        Milestone(name: 'Testing', targetProgress: 0.9, isCompleted: false),
        Milestone(name: 'Deploy', targetProgress: 1.0, isCompleted: false),
      ],
    ),
    Goal(
      title: 'Learn New Language',
      currentProgress: 0.30,
      milestones: [
        Milestone(name: 'Basics', targetProgress: 0.2, isCompleted: true),
        Milestone(name: 'Grammar', targetProgress: 0.4, isCompleted: false),
        Milestone(name: 'Conv.', targetProgress: 0.7, isCompleted: false),
        Milestone(name: 'Fluency', targetProgress: 1.0, isCompleted: false),
      ],
    ),
    Goal(
      title: 'Workout Challenge',
      currentProgress: 0.90,
      milestones: [
        Milestone(name: 'Week 1', targetProgress: 0.25, isCompleted: true),
        Milestone(name: 'Week 2', targetProgress: 0.5, isCompleted: true),
        Milestone(name: 'Week 3', targetProgress: 0.75, isCompleted: true),
        Milestone(name: 'Week 4', targetProgress: 1.0, isCompleted: false),
      ],
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('My Goals'),
      ),
      body: ListView.builder(
        itemCount: _goals.length,
        itemBuilder: (context, index) {
          return GoalTrackerWidget(goal: _goals[index]);
        },
      ),
    );
  }
}

Conclusion

By combining Flutter's powerful widget tree with the flexibility of CustomPainter, we've successfully built a sophisticated Goal Tracker Widget with a dynamic Milestone Indicator. This approach allows for highly customized visuals that can adapt to various design requirements and provide a clear, engaging overview of progress towards any objective. You can further enhance this widget by adding interactive elements, animations for progress updates, or different visual styles for milestones.

Related Articles

May 14, 2026

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter Countdown timers are essential in many applic

May 11, 2026

Unleashing Dynamic UIs: Flutter's Animation Prowess

Unleashing Dynamic UIs: Flutter's Animation Prowess for Slide & Scale Effects Flutter's declarative UI framework, combined with its powerful animation capabilit

May 11, 2026

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy A well-designed Product Detail Page (PDP) is