Advanced Features Capstone: Building a CLI Application
Capstone Project Overview
In this final lesson of the Dart Advanced Features tutorial, you will build a complete command-line application called TaskFlow — a task management and productivity tool. This project combines every advanced feature you have learned throughout this tutorial into a single, cohesive application.
TaskFlow is a CLI app that lets users manage tasks, fetch motivational quotes from an API, generate reports, and persist data to files. It exercises the following advanced Dart features:
- Async/Await — for API calls and file I/O
- Streams — for real-time progress updates and event monitoring
- Isolates — for CPU-intensive report generation
- Records & Destructuring — for structured return values
- Pattern Matching — for command routing and data validation
- Functional Programming — for data transformations and pipelines
- File I/O & JSON — for data persistence
- Packages & Libraries — for modular code organization
Project Structure
A well-organized project structure is the foundation of maintainable software. TaskFlow follows Dart package conventions with clear separation of concerns.
TaskFlow Project Layout
taskflow/
bin/
taskflow.dart # Entry point (main function)
lib/
taskflow.dart # Barrel file (public API)
src/
models/
task.dart # Task data model
report.dart # Report data model
services/
task_service.dart # Business logic for tasks
api_service.dart # HTTP API client
report_service.dart # Report generation (with isolates)
storage/
file_storage.dart # JSON file persistence
cli/
command_router.dart # Pattern-matching command router
display.dart # Terminal output formatting
utils/
extensions.dart # String and DateTime extensions
validators.dart # Input validation
test/
task_service_test.dart
file_storage_test.dart
pubspec.yaml
Step 1: Data Models with Records
We start by defining our core data models. The Task model uses records for structured metadata, and includes JSON serialization for file persistence.
Task Model (lib/src/models/task.dart)
import 'dart:convert';
/// Priority levels for tasks.
enum Priority { low, medium, high, critical }
/// Status of a task through its lifecycle.
enum TaskStatus { pending, inProgress, completed, cancelled }
/// Represents a single task with metadata.
class Task {
final String id;
final String title;
final String description;
final Priority priority;
final TaskStatus status;
final DateTime createdAt;
final DateTime? completedAt;
final List<String> tags;
final Duration estimatedDuration;
Task({
required this.id,
required this.title,
this.description = '',
this.priority = Priority.medium,
this.status = TaskStatus.pending,
DateTime? createdAt,
this.completedAt,
this.tags = const [],
this.estimatedDuration = const Duration(hours: 1),
}) : createdAt = createdAt ?? DateTime.now();
/// Create a modified copy of this task.
Task copyWith({
String? title,
String? description,
Priority? priority,
TaskStatus? status,
DateTime? completedAt,
List<String>? tags,
Duration? estimatedDuration,
}) {
return Task(
id: id,
title: title ?? this.title,
description: description ?? this.description,
priority: priority ?? this.priority,
status: status ?? this.status,
createdAt: createdAt,
completedAt: completedAt ?? this.completedAt,
tags: tags ?? this.tags,
estimatedDuration: estimatedDuration ?? this.estimatedDuration,
);
}
/// Return a summary record with key task info.
/// Uses Dart 3 records for structured return values.
({String title, Priority priority, TaskStatus status, int daysOld})
get summary => (
title: title,
priority: priority,
status: status,
daysOld: DateTime.now().difference(createdAt).inDays,
);
/// JSON serialization.
factory Task.fromJson(Map<String, dynamic> json) => Task(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String? ?? '',
priority: Priority.values.byName(json['priority'] as String),
status: TaskStatus.values.byName(json['status'] as String),
createdAt: DateTime.parse(json['created_at'] as String),
completedAt: json['completed_at'] != null
? DateTime.parse(json['completed_at'] as String)
: null,
tags: (json['tags'] as List<dynamic>?)?.cast<String>() ?? [],
estimatedDuration:
Duration(minutes: json['estimated_minutes'] as int? ?? 60),
);
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'description': description,
'priority': priority.name,
'status': status.name,
'created_at': createdAt.toIso8601String(),
'completed_at': completedAt?.toIso8601String(),
'tags': tags,
'estimated_minutes': estimatedDuration.inMinutes,
};
@override
String toString() => 'Task($title, $priority, $status)';
}
Report Model (lib/src/models/report.dart)
/// A generated report with statistics from task data.
class TaskReport {
final DateTime generatedAt;
final int totalTasks;
final int completedTasks;
final int pendingTasks;
final int overdueTasks;
final Map<String, int> tasksByPriority;
final Map<String, int> tasksByTag;
final Duration totalEstimatedTime;
final double completionRate;
TaskReport({
required this.generatedAt,
required this.totalTasks,
required this.completedTasks,
required this.pendingTasks,
required this.overdueTasks,
required this.tasksByPriority,
required this.tasksByTag,
required this.totalEstimatedTime,
required this.completionRate,
});
Map<String, dynamic> toJson() => {
'generated_at': generatedAt.toIso8601String(),
'total_tasks': totalTasks,
'completed_tasks': completedTasks,
'pending_tasks': pendingTasks,
'overdue_tasks': overdueTasks,
'tasks_by_priority': tasksByPriority,
'tasks_by_tag': tasksByTag,
'total_estimated_hours': totalEstimatedTime.inMinutes / 60,
'completion_rate': completionRate,
};
}
Step 2: File Storage with JSON Persistence
The storage layer handles reading and writing tasks to a JSON file. It uses the async file I/O and atomic write patterns from Lesson 13.
File Storage Service (lib/src/storage/file_storage.dart)
import 'dart:io';
import 'dart:convert';
import '../models/task.dart';
/// Persists tasks to a JSON file on disk.
class FileStorage {
final String filePath;
FileStorage(this.filePath);
/// Load all tasks from the JSON file.
Future<List<Task>> loadTasks() async {
final file = File(filePath);
if (!await file.exists()) {
return [];
}
try {
final contents = await file.readAsString();
if (contents.trim().isEmpty) return [];
final jsonList = jsonDecode(contents) as List<dynamic>;
return jsonList
.map((json) => Task.fromJson(json as Map<String, dynamic>))
.toList();
} on FormatException catch (e) {
print('Warning: Corrupted data file. Starting fresh. ($e)');
return [];
}
}
/// Save all tasks to the JSON file (atomic write).
Future<void> saveTasks(List<Task> tasks) async {
final file = File(filePath);
await file.parent.create(recursive: true);
final jsonList = tasks.map((t) => t.toJson()).toList();
final jsonString = const JsonEncoder.withIndent(' ').convert(jsonList);
// Atomic write: write to temp file, then rename
final tempFile = File('$filePath.tmp');
await tempFile.writeAsString(jsonString);
await tempFile.rename(filePath);
}
/// Append a single task without rewriting the entire file.
/// For large datasets, this is more efficient than saveTasks.
Future<void> appendTask(Task task) async {
final tasks = await loadTasks();
tasks.add(task);
await saveTasks(tasks);
}
/// Export tasks to CSV format.
Future<void> exportToCsv(List<Task> tasks, String outputPath) async {
final sink = File(outputPath).openWrite();
// Header row
sink.writeln('id,title,priority,status,created_at,tags');
// Data rows
for (final task in tasks) {
final escapedTitle = task.title.replaceAll(',', ';');
final tagsStr = task.tags.join(';');
sink.writeln(
'${task.id},$escapedTitle,${task.priority.name},'
'${task.status.name},${task.createdAt.toIso8601String()},$tagsStr',
);
}
await sink.flush();
await sink.close();
}
}
Step 3: API Service with Async/Await
The API service fetches motivational quotes from an external API. It demonstrates async/await, error handling, and JSON parsing from network responses.
API Service (lib/src/services/api_service.dart)
import 'dart:io';
import 'dart:convert';
import 'dart:async';
/// Fetches data from external APIs.
class ApiService {
final HttpClient _client;
final Duration _timeout;
ApiService({Duration timeout = const Duration(seconds: 10)})
: _client = HttpClient(),
_timeout = timeout {
_client.connectionTimeout = _timeout;
}
/// Fetch a motivational quote.
/// Returns a record with the quote text and author.
Future<({String text, String author})> fetchQuote() async {
try {
final uri = Uri.parse('https://api.quotable.io/random');
final request = await _client.getUrl(uri);
final response = await request.close().timeout(_timeout);
if (response.statusCode != 200) {
return (
text: 'Stay focused and never give up!',
author: 'Unknown',
);
}
final body = await response.transform(utf8.decoder).join();
final json = jsonDecode(body) as Map<String, dynamic>;
return (
text: json['content'] as String? ?? 'Keep going!',
author: json['author'] as String? ?? 'Unknown',
);
} on TimeoutException {
return (text: 'Time is precious. Use it wisely.', author: 'System');
} on SocketException {
return (text: 'Work offline. Stay productive.', author: 'System');
} catch (e) {
return (text: 'Every step forward counts.', author: 'System');
}
}
/// Fetch multiple quotes as a stream.
/// Yields one quote at a time with a delay between requests.
Stream<({String text, String author})> fetchQuoteStream(int count) async* {
for (var i = 0; i < count; i++) {
yield await fetchQuote();
if (i < count - 1) {
await Future.delayed(const Duration(milliseconds: 500));
}
}
}
void close() => _client.close();
}
({String text, String author}) as return types. This avoids creating a full class just for a simple structured return value. Records are perfect for lightweight data bundling in service methods.Step 4: Task Service with Functional Programming
The task service contains the core business logic. It uses functional programming patterns (map, where, fold, chaining) to transform and query task data.
Task Service (lib/src/services/task_service.dart)
import 'dart:math';
import '../models/task.dart';
import '../storage/file_storage.dart';
/// Core business logic for managing tasks.
class TaskService {
final FileStorage _storage;
List<Task> _tasks = [];
TaskService(this._storage);
List<Task> get tasks => List.unmodifiable(_tasks);
/// Load tasks from storage.
Future<void> initialize() async {
_tasks = await _storage.loadTasks();
}
/// Save current state to storage.
Future<void> save() async {
await _storage.saveTasks(_tasks);
}
/// Add a new task.
Future<Task> addTask({
required String title,
String description = '',
Priority priority = Priority.medium,
List<String> tags = const [],
Duration estimatedDuration = const Duration(hours: 1),
}) async {
final task = Task(
id: _generateId(),
title: title,
description: description,
priority: priority,
tags: tags,
estimatedDuration: estimatedDuration,
);
_tasks.add(task);
await save();
return task;
}
/// Update task status using pattern matching.
Future<Task?> updateStatus(String id, TaskStatus newStatus) async {
final index = _tasks.indexWhere((t) => t.id == id);
if (index == -1) return null;
final task = _tasks[index];
final updated = switch (newStatus) {
TaskStatus.completed => task.copyWith(
status: newStatus,
completedAt: DateTime.now(),
),
TaskStatus.cancelled => task.copyWith(status: newStatus),
TaskStatus.inProgress => task.copyWith(status: newStatus),
TaskStatus.pending => task.copyWith(
status: newStatus,
completedAt: null,
),
};
_tasks[index] = updated;
await save();
return updated;
}
/// Delete a task by ID.
Future<bool> deleteTask(String id) async {
final removed = _tasks.length;
_tasks.removeWhere((t) => t.id == id);
if (_tasks.length < removed) {
await save();
return true;
}
return false;
}
// ===== Functional Programming Queries =====
/// Get tasks filtered by status using where().
List<Task> byStatus(TaskStatus status) =>
_tasks.where((t) => t.status == status).toList();
/// Get tasks filtered by priority using where().
List<Task> byPriority(Priority priority) =>
_tasks.where((t) => t.priority == priority).toList();
/// Get tasks matching any of the given tags.
List<Task> byTags(List<String> tags) =>
_tasks.where((t) => t.tags.any((tag) => tags.contains(tag))).toList();
/// Search tasks by title (case-insensitive).
List<Task> search(String query) {
final lowerQuery = query.toLowerCase();
return _tasks
.where((t) =>
t.title.toLowerCase().contains(lowerQuery) ||
t.description.toLowerCase().contains(lowerQuery))
.toList();
}
/// Sort tasks by priority (critical first) then by creation date.
List<Task> sorted() {
return [..._tasks]..sort((a, b) {
final priorityCompare =
b.priority.index.compareTo(a.priority.index);
if (priorityCompare != 0) return priorityCompare;
return a.createdAt.compareTo(b.createdAt);
});
}
/// Get task summaries using records and map().
List<({String title, Priority priority, TaskStatus status, int daysOld})>
get summaries => _tasks.map((t) => t.summary).toList();
/// Calculate total estimated time using fold().
Duration get totalEstimatedTime =>
_tasks.fold(Duration.zero, (sum, t) => sum + t.estimatedDuration);
/// Calculate completion rate.
double get completionRate {
if (_tasks.isEmpty) return 0;
return _tasks.where((t) => t.status == TaskStatus.completed).length /
_tasks.length;
}
/// Get all unique tags using expand() and toSet().
Set<String> get allTags => _tasks.expand((t) => t.tags).toSet();
/// Group tasks by status using fold().
Map<TaskStatus, List<Task>> get groupedByStatus {
return _tasks.fold<Map<TaskStatus, List<Task>>>(
{},
(map, task) {
map.putIfAbsent(task.status, () => []).add(task);
return map;
},
);
}
/// Pipeline: get overdue high-priority tasks, sorted by age.
List<Task> get urgentTasks => _tasks
.where((t) => t.status == TaskStatus.pending)
.where((t) =>
t.priority == Priority.high || t.priority == Priority.critical)
.where((t) => DateTime.now().difference(t.createdAt).inDays > 3)
.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
String _generateId() {
final random = Random();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final suffix = random.nextInt(9999).toString().padLeft(4, '0');
return 'task_${timestamp}_$suffix';
}
}
Step 5: Report Generation with Isolates
Report generation can be computationally expensive for large datasets. We offload it to an isolate to keep the main thread responsive, and use a stream to report progress.
Report Service (lib/src/services/report_service.dart)
import 'dart:async';
import 'dart:isolate';
import 'dart:convert';
import 'dart:io';
import '../models/task.dart';
import '../models/report.dart';
/// Generates reports, using isolates for heavy computation.
class ReportService {
/// Generate a report in a background isolate.
/// Returns a stream of progress messages, with the final
/// message containing the completed report.
Stream<String> generateReport(List<Task> tasks) async* {
yield 'Starting report generation...';
// For small datasets, generate inline
if (tasks.length < 100) {
yield 'Processing ${tasks.length} tasks...';
final report = _computeReport(tasks.map((t) => t.toJson()).toList());
yield 'Report generated successfully.';
yield 'REPORT_JSON:${jsonEncode(report.toJson())}';
return;
}
// For large datasets, use an isolate
yield 'Large dataset detected. Using background processing...';
final receivePort = ReceivePort();
final taskJsonList = tasks.map((t) => t.toJson()).toList();
await Isolate.spawn(
_isolateEntryPoint,
(receivePort.sendPort, taskJsonList),
);
await for (final message in receivePort) {
if (message is String) {
yield message;
if (message.startsWith('REPORT_JSON:')) {
receivePort.close();
break;
}
}
}
}
/// Entry point for the background isolate.
static void _isolateEntryPoint(
(SendPort sendPort, List<Map<String, dynamic>> taskData) args,
) {
final (sendPort, taskData) = args;
sendPort.send('Processing ${taskData.length} tasks in isolate...');
// Simulate heavy computation with progress updates
final quarter = taskData.length ~/ 4;
for (var i = 0; i < taskData.length; i++) {
if (i == quarter) sendPort.send('25% complete...');
if (i == quarter * 2) sendPort.send('50% complete...');
if (i == quarter * 3) sendPort.send('75% complete...');
}
final report = _computeReport(taskData);
sendPort.send('100% complete. Finalizing report...');
sendPort.send('REPORT_JSON:${jsonEncode(report.toJson())}');
}
/// Pure function that computes report statistics.
static TaskReport _computeReport(List<Map<String, dynamic>> taskData) {
final tasks = taskData.map((j) => Task.fromJson(j)).toList();
final completed =
tasks.where((t) => t.status == TaskStatus.completed).length;
final pending =
tasks.where((t) => t.status == TaskStatus.pending).length;
final overdue = tasks
.where((t) =>
t.status == TaskStatus.pending &&
DateTime.now().difference(t.createdAt).inDays > 7)
.length;
// Count by priority
final byPriority = <String, int>{};
for (final p in Priority.values) {
final count = tasks.where((t) => t.priority == p).length;
if (count > 0) byPriority[p.name] = count;
}
// Count by tag
final byTag = <String, int>{};
for (final task in tasks) {
for (final tag in task.tags) {
byTag[tag] = (byTag[tag] ?? 0) + 1;
}
}
final totalTime = tasks.fold<Duration>(
Duration.zero,
(sum, t) => sum + t.estimatedDuration,
);
return TaskReport(
generatedAt: DateTime.now(),
totalTasks: tasks.length,
completedTasks: completed,
pendingTasks: pending,
overdueTasks: overdue,
tasksByPriority: byPriority,
tasksByTag: byTag,
totalEstimatedTime: totalTime,
completionRate: tasks.isEmpty ? 0 : completed / tasks.length,
);
}
/// Save a report to a JSON file.
Future<void> saveReport(TaskReport report, String path) async {
final json = const JsonEncoder.withIndent(' ').convert(report.toJson());
await File(path).writeAsString(json);
}
}
final (sendPort, taskData) = args;. The data passed to the isolate must be serializable (primitives, lists, maps), so we convert tasks to JSON maps before sending and reconstruct them inside the isolate.Step 6: Command Router with Pattern Matching
The command router parses user input and dispatches to the appropriate handler. It uses Dart 3 pattern matching extensively for clean, expressive command parsing.
Command Router (lib/src/cli/command_router.dart)
import 'dart:async';
import 'dart:io';
import '../models/task.dart';
import '../services/task_service.dart';
import '../services/api_service.dart';
import '../services/report_service.dart';
import 'display.dart';
/// Sealed class representing all possible commands.
sealed class Command {}
class AddCommand extends Command {
final String title;
final Priority priority;
final List<String> tags;
AddCommand(this.title, this.priority, this.tags);
}
class ListCommand extends Command {
final TaskStatus? filterStatus;
ListCommand([this.filterStatus]);
}
class CompleteCommand extends Command {
final String taskId;
CompleteCommand(this.taskId);
}
class DeleteCommand extends Command {
final String taskId;
DeleteCommand(this.taskId);
}
class SearchCommand extends Command {
final String query;
SearchCommand(this.query);
}
class ReportCommand extends Command {}
class QuoteCommand extends Command {}
class HelpCommand extends Command {}
class ExitCommand extends Command {}
class UnknownCommand extends Command {
final String input;
UnknownCommand(this.input);
}
/// Parses user input into typed Command objects using pattern matching.
class CommandParser {
Command parse(String input) {
final parts = input.trim().split(RegExp(r'\s+'));
if (parts.isEmpty || parts.first.isEmpty) return HelpCommand();
return switch (parts) {
['add', ...final rest] => _parseAdd(rest),
['list'] => ListCommand(),
['list', final status] => _parseList(status),
['complete', final id] => CompleteCommand(id),
['done', final id] => CompleteCommand(id),
['delete', final id] => DeleteCommand(id),
['remove', final id] => DeleteCommand(id),
['search', ...final rest] => SearchCommand(rest.join(' ')),
['find', ...final rest] => SearchCommand(rest.join(' ')),
['report'] => ReportCommand(),
['quote'] => QuoteCommand(),
['help'] => HelpCommand(),
['exit'] || ['quit'] || ['q'] => ExitCommand(),
_ => UnknownCommand(input),
};
}
AddCommand _parseAdd(List<String> args) {
var priority = Priority.medium;
final tags = <String>[];
final titleParts = <String>[];
for (final arg in args) {
if (arg.startsWith('--priority=') || arg.startsWith('-p=')) {
final value = arg.split('=').last;
priority = switch (value) {
'low' || 'l' => Priority.low,
'medium' || 'm' => Priority.medium,
'high' || 'h' => Priority.high,
'critical' || 'c' => Priority.critical,
_ => Priority.medium,
};
} else if (arg.startsWith('--tag=') || arg.startsWith('-t=')) {
tags.add(arg.split('=').last);
} else {
titleParts.add(arg);
}
}
return AddCommand(
titleParts.join(' '),
priority,
tags,
);
}
ListCommand _parseList(String status) {
final taskStatus = switch (status) {
'pending' || 'p' => TaskStatus.pending,
'active' || 'a' || 'in-progress' => TaskStatus.inProgress,
'completed' || 'done' || 'c' => TaskStatus.completed,
'cancelled' || 'x' => TaskStatus.cancelled,
_ => null,
};
return ListCommand(taskStatus);
}
}
/// Executes commands using pattern matching on the Command type.
class CommandExecutor {
final TaskService _taskService;
final ApiService _apiService;
final ReportService _reportService;
final Display _display;
CommandExecutor(
this._taskService,
this._apiService,
this._reportService,
this._display,
);
/// Execute a command and return whether to continue the loop.
Future<bool> execute(Command command) async {
switch (command) {
case AddCommand(:final title, :final priority, :final tags):
final task = await _taskService.addTask(
title: title,
priority: priority,
tags: tags,
);
_display.taskAdded(task);
case ListCommand(:final filterStatus):
final tasks = filterStatus != null
? _taskService.byStatus(filterStatus)
: _taskService.sorted();
_display.taskList(tasks, filter: filterStatus?.name);
case CompleteCommand(:final taskId):
final task = await _taskService.updateStatus(
taskId,
TaskStatus.completed,
);
if (task != null) {
_display.taskCompleted(task);
} else {
_display.error('Task not found: $taskId');
}
case DeleteCommand(:final taskId):
final deleted = await _taskService.deleteTask(taskId);
if (deleted) {
_display.success('Task deleted.');
} else {
_display.error('Task not found: $taskId');
}
case SearchCommand(:final query):
final results = _taskService.search(query);
_display.searchResults(results, query);
case ReportCommand():
await for (final msg
in _reportService.generateReport(_taskService.tasks)) {
if (msg.startsWith('REPORT_JSON:')) {
_display.report(msg.substring('REPORT_JSON:'.length));
} else {
_display.info(msg);
}
}
case QuoteCommand():
final quote = await _apiService.fetchQuote();
_display.quote(quote.text, quote.author);
case HelpCommand():
_display.help();
case ExitCommand():
_display.goodbye();
return false;
case UnknownCommand(:final input):
_display.error('Unknown command: "$input". Type "help" for usage.');
}
return true;
}
}
switch in execute() does not need a default case. Because Command is a sealed class, the Dart compiler knows all possible subtypes and verifies the switch is exhaustive. If you add a new command subclass later, the compiler will flag every switch that does not handle it.Step 7: Display Formatting with Extensions
The display module handles all terminal output formatting, using extensions to add useful methods to existing types.
Display and Extensions
// ====== lib/src/utils/extensions.dart ======
/// Useful extensions on String.
extension StringExt on String {
String get capitalized {
if (isEmpty) return this;
return '${this[0].toUpperCase()}${substring(1)}';
}
/// Pad or truncate to exactly [width] characters.
String fixedWidth(int width) {
if (length >= width) return substring(0, width);
return padRight(width);
}
}
/// Extensions on DateTime.
extension DateTimeExt on DateTime {
String get shortDate =>
'${year}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
String get timeAgo {
final diff = DateTime.now().difference(this);
if (diff.inDays > 0) return '${diff.inDays}d ago';
if (diff.inHours > 0) return '${diff.inHours}h ago';
if (diff.inMinutes > 0) return '${diff.inMinutes}m ago';
return 'just now';
}
}
// ====== lib/src/cli/display.dart ======
import 'dart:convert';
import '../models/task.dart';
import '../utils/extensions.dart';
/// Formats and prints output to the terminal.
class Display {
void banner() {
print('');
print('==========================================');
print(' TaskFlow - Advanced Task Manager');
print('==========================================');
print('');
}
void prompt() => stdout.write('taskflow> ');
void taskAdded(Task task) {
print('Task added: "${task.title}" [${task.priority.name}]');
print(' ID: ${task.id}');
if (task.tags.isNotEmpty) print(' Tags: ${task.tags.join(', ')}');
}
void taskCompleted(Task task) {
print('Task completed: "${task.title}"');
}
void taskList(List<Task> tasks, {String? filter}) {
if (tasks.isEmpty) {
print(filter != null ? 'No $filter tasks.' : 'No tasks yet.');
return;
}
print('');
final header = filter != null
? '${filter.capitalized} Tasks (${tasks.length})'
: 'All Tasks (${tasks.length})';
print(header);
print('-' * header.length);
for (final task in tasks) {
final status = switch (task.status) {
TaskStatus.pending => '[ ]',
TaskStatus.inProgress => '[~]',
TaskStatus.completed => '[x]',
TaskStatus.cancelled => '[-]',
};
final priority = switch (task.priority) {
Priority.critical => '!!!',
Priority.high => '!! ',
Priority.medium => '! ',
Priority.low => '. ',
};
print(' $status $priority ${task.title.fixedWidth(40)} '
'${task.createdAt.timeAgo}');
print(' ID: ${task.id}');
}
print('');
}
void searchResults(List<Task> results, String query) {
print('Search results for "$query": ${results.length} found');
if (results.isNotEmpty) {
taskList(results);
}
}
void report(String reportJson) {
final data = jsonDecode(reportJson) as Map<String, dynamic>;
print('');
print('========== TASK REPORT ==========');
print('Generated: ${data['generated_at']}');
print('');
print('Total Tasks: ${data['total_tasks']}');
print('Completed: ${data['completed_tasks']}');
print('Pending: ${data['pending_tasks']}');
print('Overdue: ${data['overdue_tasks']}');
final rate = (data['completion_rate'] as num) * 100;
print('Completion Rate: ${rate.toStringAsFixed(1)}%');
print('Est. Hours: ${data['total_estimated_hours']}');
if (data['tasks_by_priority'] case Map byPriority
when byPriority.isNotEmpty) {
print('');
print('By Priority:');
byPriority.forEach((k, v) => print(' $k: $v'));
}
if (data['tasks_by_tag'] case Map byTag when byTag.isNotEmpty) {
print('');
print('By Tag:');
byTag.forEach((k, v) => print(' #$k: $v'));
}
print('================================');
print('');
}
void quote(String text, String author) {
print('');
print(' "$text"');
print(' - $author');
print('');
}
void help() {
print('');
print('Available Commands:');
print(' add <title> [-p=priority] [-t=tag] Add a new task');
print(' list [status] List tasks');
print(' complete <id> Mark task done');
print(' delete <id> Remove a task');
print(' search <query> Search tasks');
print(' report Generate report');
print(' quote Get motivation');
print(' help Show this help');
print(' exit Quit TaskFlow');
print('');
print('Priority: low(l), medium(m), high(h), critical(c)');
print('Status: pending(p), active(a), completed(c), cancelled(x)');
print('');
}
void goodbye() {
print('');
print('Goodbye! Your tasks are saved.');
}
void info(String msg) => print('[info] $msg');
void success(String msg) => print('[ok] $msg');
void error(String msg) => print('[error] $msg');
}
Step 8: Main Entry Point — Putting It All Together
The entry point wires all components together and runs the interactive command loop.
Application Entry Point (bin/taskflow.dart)
import 'dart:io';
import 'package:taskflow/src/services/task_service.dart';
import 'package:taskflow/src/services/api_service.dart';
import 'package:taskflow/src/services/report_service.dart';
import 'package:taskflow/src/storage/file_storage.dart';
import 'package:taskflow/src/cli/command_router.dart';
import 'package:taskflow/src/cli/display.dart';
Future<void> main(List<String> arguments) async {
// Initialize services
final storage = FileStorage('data/tasks.json');
final taskService = TaskService(storage);
final apiService = ApiService();
final reportService = ReportService();
final display = Display();
final parser = CommandParser();
final executor = CommandExecutor(
taskService,
apiService,
reportService,
display,
);
// Load existing tasks
await taskService.initialize();
// Show welcome banner
display.banner();
display.info('Loaded ${taskService.tasks.length} tasks.');
// Fetch a motivational quote on startup
final quote = await apiService.fetchQuote();
display.quote(quote.text, quote.author);
// Interactive command loop
var running = true;
while (running) {
display.prompt();
final input = stdin.readLineSync()?.trim() ?? '';
if (input.isEmpty) continue;
try {
final command = parser.parse(input);
running = await executor.execute(command);
} catch (e) {
display.error('Unexpected error: $e');
}
}
// Cleanup
apiService.close();
}
Step 9: Stream-Based Progress Monitor
Let’s add a stream-based feature that monitors task changes in real-time. This demonstrates stream controllers and broadcast streams.
Event Stream for Task Changes
import 'dart:async';
import '../models/task.dart';
/// Types of events that can occur.
enum TaskEventType { added, updated, deleted, completed }
/// Represents a change event on a task.
class TaskEvent {
final TaskEventType type;
final Task task;
final DateTime timestamp;
TaskEvent(this.type, this.task) : timestamp = DateTime.now();
@override
String toString() => '[${timestamp.toIso8601String()}] '
'${type.name}: ${task.title}';
}
/// Mixin that adds event streaming to any service.
mixin TaskEventEmitter {
final _controller = StreamController<TaskEvent>.broadcast();
/// Stream of task events for listeners.
Stream<TaskEvent> get events => _controller.stream;
/// Emit a task event.
void emit(TaskEventType type, Task task) {
_controller.add(TaskEvent(type, task));
}
/// Close the event stream.
void disposeEvents() => _controller.close();
}
// Usage: Add the mixin to TaskService
// class TaskService with TaskEventEmitter { ... }
//
// Then in the service methods:
// emit(TaskEventType.added, task);
// emit(TaskEventType.completed, task);
//
// And in the main app, subscribe:
// taskService.events.listen((event) {
// print('Event: $event');
// });
//
// Filter specific events:
// taskService.events
// .where((e) => e.type == TaskEventType.completed)
// .listen((e) => print('Congrats! Completed: ${e.task.title}'));
Step 10: Input Validation
Robust input validation uses pattern matching and guard clauses to ensure data integrity.
Validators (lib/src/utils/validators.dart)
/// Validation result using a sealed class.
sealed class ValidationResult {}
class Valid extends ValidationResult {}
class Invalid extends ValidationResult {
final String message;
Invalid(this.message);
}
/// Input validators for the CLI.
class Validators {
/// Validate a task title.
static ValidationResult validateTitle(String title) => switch (title) {
'' => Invalid('Title cannot be empty.'),
String s when s.length < 3 =>
Invalid('Title must be at least 3 characters.'),
String s when s.length > 200 =>
Invalid('Title must be under 200 characters.'),
_ => Valid(),
};
/// Validate a task ID format.
static ValidationResult validateTaskId(String id) => switch (id) {
'' => Invalid('Task ID cannot be empty.'),
String s when !s.startsWith('task_') =>
Invalid('Invalid task ID format.'),
_ => Valid(),
};
/// Validate a priority string.
static Priority? parsePriority(String input) => switch (input.toLowerCase()) {
'low' || 'l' || '1' => Priority.low,
'medium' || 'm' || 'med' || '2' => Priority.medium,
'high' || 'h' || '3' => Priority.high,
'critical' || 'c' || 'crit' || '4' => Priority.critical,
_ => null,
};
}
// Usage with pattern matching on the result:
void addTask(String title) {
switch (Validators.validateTitle(title)) {
case Valid():
print('Creating task: $title');
case Invalid(:final message):
print('Validation error: $message');
}
}
Features Summary: Advanced Concepts Used
Here is a recap of every advanced Dart feature exercised in this capstone project and where it appears:
Feature Map
// Feature | Where Used
// ======================== | ========================================
// async/await | ApiService.fetchQuote()
// | FileStorage.loadTasks() / saveTasks()
// | TaskService.addTask() / updateStatus()
// | CommandExecutor.execute()
//
// Streams | ApiService.fetchQuoteStream()
// | ReportService.generateReport()
// | TaskEventEmitter.events
//
// Isolates | ReportService (large dataset reports)
//
// Records | ApiService return types
// | Task.summary getter
// | Isolate entry point arguments
//
// Pattern Matching | CommandParser.parse() — list patterns
// | CommandExecutor.execute() — sealed class
// | Display — switch expressions
// | Validators — guard clauses
//
// Sealed Classes | Command hierarchy
// | ValidationResult
//
// Functional Programming | TaskService queries (map, where, fold)
// | Pipeline: urgentTasks getter
// | allTags with expand() + toSet()
//
// File I/O | FileStorage (read, write, atomic)
// | ReportService.saveReport()
// | FileStorage.exportToCsv()
//
// JSON Processing | Task.fromJson() / toJson()
// | Report serialization
// | API response parsing
//
// Extensions | StringExt, DateTimeExt
//
// Packages / Libraries | Barrel file pattern
// | Modular src/ organization
// | Public API control
Running and Testing the Application
Here is a sample session showing the application in action, demonstrating all the key features working together.
Sample Session
# Terminal session:
$ dart run bin/taskflow.dart
==========================================
TaskFlow - Advanced Task Manager
==========================================
[info] Loaded 0 tasks.
"The only way to do great work is to love what you do."
- Steve Jobs
taskflow> add Build login page -p=high -t=frontend -t=auth
Task added: "Build login page" [high]
ID: task_1713200000_4821
Tags: frontend, auth
taskflow> add Write API tests -p=medium -t=backend
Task added: "Write API tests" [medium]
ID: task_1713200015_7392
taskflow> add Fix critical security bug -p=critical -t=security
Task added: "Fix critical security bug" [critical]
ID: task_1713200030_1156
taskflow> list
All Tasks (3)
-------------
[ ] !!! Fix critical security bug just now
ID: task_1713200030_1156
[ ] !! Build login page just now
ID: task_1713200000_4821
[ ] ! Write API tests just now
ID: task_1713200015_7392
taskflow> complete task_1713200000_4821
Task completed: "Build login page"
taskflow> search security
Search results for "security": 1 found
[ ] !!! Fix critical security bug just now
taskflow> report
[info] Starting report generation...
[info] Processing 3 tasks...
[info] Report generated successfully.
========== TASK REPORT ==========
Generated: 2024-04-15T14:30:00.000
Total Tasks: 3
Completed: 1
Pending: 2
Overdue: 0
Completion Rate: 33.3%
Est. Hours: 3.0
By Priority:
critical: 1
high: 1
medium: 1
By Tag:
#frontend: 1
#auth: 1
#backend: 1
#security: 1
================================
taskflow> quote
"In the middle of difficulty lies opportunity."
- Albert Einstein
taskflow> exit
Goodbye! Your tasks are saved.
Next Steps and Enhancements
You now have a solid foundation for a Dart CLI application. Here are ideas for extending it further:
- Add
json_serializable— Replace hand-writtenfromJson/toJsonwith code generation (from Lesson 15) - Add
freezed— Make Task an immutable data class with auto-generatedcopyWith,==, andtoString - Add sub-tasks — Nested task hierarchies with recursive processing
- Add due dates — Deadline tracking with stream-based reminders
- Add categories — Task grouping with pattern matching filters
- Add undo/redo — Command pattern with a history stack
- Export to HTML — Generate styled HTML reports using file I/O
- Add unit tests — Test each service independently
- Publish as a package — Share your CLI tool on pub.dev
Summary
Congratulations on completing the Dart Advanced Features tutorial! In this capstone, you built a complete CLI application that combines:
- Async/Await for non-blocking API calls and file I/O operations
- Streams for real-time progress reporting and event monitoring
- Isolates for CPU-intensive report generation without blocking the UI
- Records for lightweight structured data like API responses and task summaries
- Pattern Matching for command routing, validation, and display formatting
- Sealed Classes for exhaustive command handling and result types
- Functional Programming for data queries, transformations, and pipelines
- File I/O & JSON for persistent storage with atomic writes
- Extensions for adding utility methods to String and DateTime
- Package Structure for modular, maintainable code organization
You now have the advanced Dart skills needed to build sophisticated applications. Whether you move on to Flutter mobile development, server-side Dart, or command-line tools, these patterns will serve you well.