Introduction
In this comprehensive guide, I’ll show you how to build a stunning Task Manager application in Flutter that connects to a real public API. We’ll use JSONPlaceholder – a free fake API perfect for learning and prototyping. Our app will feature a beautiful Material 3 design, smooth animations, and robust state management using Riverpod.
What You’ll Learn:
- How to consume real REST APIs in Flutter
- Building beautiful, responsive UIs with Material 3
- Implementing complex state management with Riverpod
- Creating reusable, animated widgets
- Handling API errors gracefully
- Adding pull-to-refresh and infinite scrolling
- Implementing search and filtering
Project Overview
We’re building “TaskFlow” – a beautiful task management app that:
- Fetches tasks from JSONPlaceholder API
- Allows creating, updating, and deleting tasks (simulated)
- Features a stunning Material 3 design
- Includes smooth animations and transitions
- Works offline with cached data
- Supports search and filtering
- Has pull-to-refresh and infinite scrolling
Why JSONPlaceholder?
JSONPlaceholder is perfect for learning because:
- It’s free and requires no authentication
- Supports all CRUD operations (GET, POST, PUT, DELETE)
- Has realistic todo/task data
- No rate limiting for educational use
- Returns consistent, predictable data
Project Structure
lib/
├── main.dart
├── app.dart
├── core/
│ ├── api_service.dart
│ ├── exceptions.dart
│ └── theme/
│ ├── app_colors.dart
│ ├── app_text_styles.dart
│ └── app_theme.dart
├── models/
│ ├── task.dart
│ └── api_response.dart
├── providers/
│ ├── api_providers.dart
│ ├── task_providers.dart
│ └── theme_providers.dart
├── screens/
│ ├── tasks_screen.dart
│ └── task_detail_screen.dart
└── widgets/
├── task_card.dart
├── loading_shimmer.dart
├── empty_state.dart
└── error_widget.dart
Dependencies
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.0.0
http: ^1.0.0
freezed: ^2.0.0
freezed_annotation: ^2.0.0
shimmer: ^3.0.0
liquid_pull_to_refresh: ^4.0.0
cached_network_image: ^3.3.0
intl: ^0.19.0
dev_dependencies:
build_runner: ^2.0.0
riverpod_analyzer: ^2.0.0
Step 1: Setting Up the API Service
First, let’s create our API service that will communicate with JSONPlaceholder:
// core/api_service.dart
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'exceptions.dart';
// Provider for the API service
final apiServiceProvider = Provider<ApiService>((ref) {
return ApiService();
});
class ApiService {
static const String baseUrl = 'https://jsonplaceholder.typicode.com';
final Duration timeout = const Duration(seconds: 30);
// Fetch all todos (tasks) from JSONPlaceholder
Future<List<Map<String, dynamic>>> getTodos() async {
try {
final response = await http
.get(Uri.parse('$baseUrl/todos'))
.timeout(timeout);
return _handleResponse(response);
} catch (e) {
throw ApiException(message: 'Failed to fetch tasks: $e');
}
}
// Fetch a single todo by ID
Future<Map<String, dynamic>> getTodo(int id) async {
try {
final response = await http
.get(Uri.parse('$baseUrl/todos/$id'))
.timeout(timeout);
return _handleResponse(response);
} catch (e) {
throw ApiException(message: 'Failed to fetch task: $e');
}
}
// Create a new todo (simulated - JSONPlaceholder won't actually save it)
Future<Map<String, dynamic>> createTodo(Map<String, dynamic> todo) async {
try {
final response = await http
.post(
Uri.parse('$baseUrl/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo),
)
.timeout(timeout);
return _handleResponse(response);
} catch (e) {
throw ApiException(message: 'Failed to create task: $e');
}
}
// Update a todo (simulated)
Future<Map<String, dynamic>> updateTodo(int id, Map<String, dynamic> todo) async {
try {
final response = await http
.put(
Uri.parse('$baseUrl/todos/$id'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo),
)
.timeout(timeout);
return _handleResponse(response);
} catch (e) {
throw ApiException(message: 'Failed to update task: $e');
}
}
// Delete a todo (simulated)
Future<void> deleteTodo(int id) async {
try {
final response = await http
.delete(Uri.parse('$baseUrl/todos/$id'))
.timeout(timeout);
_handleResponse(response);
} catch (e) {
throw ApiException(message: 'Failed to delete task: $e');
}
}
dynamic _handleResponse(http.Response response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
if (response.body.isEmpty) return {};
return jsonDecode(response.body);
} else {
throw ApiException(
message: 'Request failed with status: ${response.statusCode}',
statusCode: response.statusCode,
);
}
}
}
// core/exceptions.dart
class ApiException implements Exception {
final String message;
final int? statusCode;
ApiException({required this.message, this.statusCode});
@override
String toString() => 'ApiException: $message';
}
Step 2: Creating Beautiful Data Models
Let’s create our Task model with Freezed for immutability and JSON serialization:
// models/task.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'task.freezed.dart';
part 'task.g.dart';
enum TaskPriority { low, medium, high }
@freezed
class Task with _$Task {
factory Task({
required int id,
required String title,
required bool completed,
required int userId,
TaskPriority? priority,
String? category,
DateTime? dueDate,
String? notes,
}) = _Task;
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
}
// Extension for Task utilities
extension TaskExtensions on Task {
// Convert from JSONPlaceholder format to our Task model
factory Task.fromTodoJson(Map<String, dynamic> json) {
// Add some random data for demo purposes
final priorities = [TaskPriority.low, TaskPriority.medium, TaskPriority.high];
final categories = ['Work', 'Personal', 'Shopping', 'Health'];
return Task(
id: json['id'] as int,
title: json['title'] as String,
completed: json['completed'] as bool,
userId: json['userId'] as int,
priority: priorities[(json['id'] as int) % priorities.length],
category: categories[(json['id'] as int) % categories.length],
dueDate: DateTime.now().add(Duration(days: (json['id'] as int) % 30)),
);
}
// Convert to JSONPlaceholder format
Map<String, dynamic> toTodoJson() {
return {
'id': id,
'title': title,
'completed': completed,
'userId': userId,
};
}
// UI helpers
String get displayId => 'TASK-${id.toString().padLeft(3, '0')}';
String get formattedDueDate {
if (dueDate == null) return 'No due date';
final now = DateTime.now();
final difference = dueDate!.difference(now);
if (difference.inDays == 0) return 'Today';
if (difference.inDays == 1) return 'Tomorrow';
if (difference.inDays == -1) return 'Yesterday';
if (difference.inDays < 0) return '${difference.inDays.abs()} days ago';
return 'In ${difference.inDays} days';
}
}
Run the code generator:
flutter pub run build_runner build
Step 3: Building the Riverpod State Management Layer
Now let’s create our Riverpod providers for state management:
// providers/task_providers.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/task.dart';
import '../core/api_service.dart';
// State class for task management
@freezed
class TaskState with _$TaskState {
const factory TaskState({
@Default(AsyncValue.loading()) AsyncValue<List<Task>> tasks,
@Default(false) bool isLoadingMore,
@Default(false) bool hasReachedMax,
@Default(1) int currentPage,
String? searchQuery,
TaskPriority? filterPriority,
String? filterCategory,
@Default(false) bool isRefreshing,
DateTime? lastUpdated,
@Default(0) int totalTasks,
@Default(0) int completedTasks,
}) = _TaskState;
}
// Main task provider
final tasksProvider = StateNotifierProvider<TaskNotifier, TaskState>(
(ref) => TaskNotifier(ref),
);
class TaskNotifier extends StateNotifier<TaskState> {
final Ref ref;
Timer? _searchTimer;
List<Task> _allTasks = [];
TaskNotifier(this.ref) : super(const TaskState()) {
_loadInitialTasks();
}
@override
void dispose() {
_searchTimer?.cancel();
super.dispose();
}
Future<void> _loadInitialTasks() async {
await _fetchTasks();
}
Future<void> _fetchTasks() async {
try {
state = state.copyWith(
tasks: const AsyncValue.loading(),
isRefreshing: true,
);
final apiService = ref.read(apiServiceProvider);
final response = await apiService.getTodos();
// Convert JSONPlaceholder todos to our Task model
final tasks = response
.map((json) => Task.fromTodoJson(json))
.cast<Task>()
.toList();
_allTasks = tasks;
// Apply filters if any
final filteredTasks = _applyFilters(tasks);
// Calculate statistics
final completedTasks = tasks.where((t) => t.completed).length;
state = state.copyWith(
tasks: AsyncValue.data(filteredTasks),
isRefreshing: false,
lastUpdated: DateTime.now(),
totalTasks: tasks.length,
completedTasks: completedTasks,
hasReachedMax: true, // JSONPlaceholder doesn't support pagination
);
} catch (e) {
state = state.copyWith(
tasks: AsyncValue.error(e, StackTrace.current),
isRefreshing: false,
);
}
}
List<Task> _applyFilters(List<Task> tasks) {
var filtered = tasks;
// Apply search filter
if (state.searchQuery != null && state.searchQuery!.isNotEmpty) {
filtered = filtered.where((task) {
return task.title.toLowerCase().contains(state.searchQuery!.toLowerCase());
}).toList();
}
// Apply priority filter
if (state.filterPriority != null) {
filtered = filtered.where((task) {
return task.priority == state.filterPriority;
}).toList();
}
// Apply category filter
if (state.filterCategory != null) {
filtered = filtered.where((task) {
return task.category == state.filterCategory;
}).toList();
}
return filtered;
}
Future<void> refreshTasks() async {
await _fetchTasks();
}
void setSearchQuery(String query) {
// Debounce search to avoid immediate filtering
_searchTimer?.cancel();
_searchTimer = Timer(const Duration(milliseconds: 500), () {
state = state.copyWith(searchQuery: query.isEmpty ? null : query);
_applyFiltersToState();
});
}
void setPriorityFilter(TaskPriority? priority) {
state = state.copyWith(filterPriority: priority);
_applyFiltersToState();
}
void setCategoryFilter(String? category) {
state = state.copyWith(filterCategory: category);
_applyFiltersToState();
}
void _applyFiltersToState() {
final filteredTasks = _applyFilters(_allTasks);
state = state.copyWith(tasks: AsyncValue.data(filteredTasks));
}
Future<void> toggleTaskCompletion(Task task) async {
try {
final apiService = ref.read(apiServiceProvider);
// Update on server (simulated)
await apiService.updateTodo(task.id, {
...task.toTodoJson(),
'completed': !task.completed,
});
// Update local state
final updatedTasks = _allTasks.map((t) {
if (t.id == task.id) {
return t.copyWith(completed: !t.completed);
}
return t;
}).toList();
_allTasks = updatedTasks;
final filteredTasks = _applyFilters(updatedTasks);
// Update statistics
final completedTasks = updatedTasks.where((t) => t.completed).length;
state = state.copyWith(
tasks: AsyncValue.data(filteredTasks),
completedTasks: completedTasks,
);
} catch (e) {
// If API call fails, revert the change
_applyFiltersToState();
rethrow;
}
}
Future<void> addTask(String title, TaskPriority priority, String category) async {
try {
final apiService = ref.read(apiServiceProvider);
// Create on server (simulated)
final response = await apiService.createTodo({
'title': title,
'completed': false,
'userId': 1, // Default user for demo
});
// Add to local state
final newTask = Task.fromTodoJson({
...response,
'id': _allTasks.length + 1, // Generate new ID
}).copyWith(
priority: priority,
category: category,
dueDate: DateTime.now().add(const Duration(days: 7)),
);
final updatedTasks = [newTask, ..._allTasks];
_allTasks = updatedTasks;
final filteredTasks = _applyFilters(updatedTasks);
state = state.copyWith(tasks: AsyncValue.data(filteredTasks));
} catch (e) {
rethrow;
}
}
Future<void> deleteTask(int taskId) async {
try {
final apiService = ref.read(apiServiceProvider);
// Delete from server (simulated)
await apiService.deleteTodo(taskId);
// Remove from local state
final updatedTasks = _allTasks.where((t) => t.id != taskId).toList();
_allTasks = updatedTasks;
final filteredTasks = _applyFilters(updatedTasks);
state = state.copyWith(tasks: AsyncValue.data(filteredTasks));
} catch (e) {
rethrow;
}
}
}
// Provider for task statistics
final taskStatsProvider = Provider<TaskStats>((ref) {
final state = ref.watch(tasksProvider);
final tasks = state.tasks.valueOrNull ?? [];
return TaskStats(
total: state.totalTasks,
completed: state.completedTasks,
pending: state.totalTasks - state.completedTasks,
completionRate: state.totalTasks > 0
? (state.completedTasks / state.totalTasks * 100).round()
: 0,
);
});
class TaskStats {
final int total;
final int completed;
final int pending;
final int completionRate;
TaskStats({
required this.total,
required this.completed,
required this.pending,
required this.completionRate,
});
}
Step 4: Creating Beautiful UI Widgets
Let’s build some stunning reusable widgets:
// widgets/task_card.dart
import 'package:flutter/material.dart';
import '../models/task.dart';
class TaskCard extends StatelessWidget {
final Task task;
final VoidCallback onToggle;
final VoidCallback onDelete;
final VoidCallback onTap;
const TaskCard({
super.key,
required this.task,
required this.onToggle,
required this.onDelete,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with priority and menu
Row(
children: [
// Priority indicator
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: task.priority?.color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: task.priority?.color.withOpacity(0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
task.priority?.icon,
size: 12,
color: task.priority?.color,
),
const SizedBox(width: 4),
Text(
task.priority?.text ?? 'Medium',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: task.priority?.color,
),
),
],
),
),
const Spacer(),
// Category badge
if (task.category != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
task.category!,
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 12),
// Task title
Text(
task.title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
decoration: task.completed
? TextDecoration.lineThrough
: null,
color: task.completed
? Colors.grey
: Colors.black,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
// Due date and ID
Row(
children: [
Icon(
Icons.calendar_today,
size: 12,
color: Colors.grey.shade600,
),
const SizedBox(width: 4),
Text(
task.formattedDueDate,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const Spacer(),
Text(
task.displayId,
style: TextStyle(
fontSize: 10,
color: Colors.grey.shade500,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 12),
// Actions row
Row(
children: [
// Toggle button
InkWell(
onTap: onToggle,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: task.completed
? Colors.green.withOpacity(0.1)
: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
task.completed
? Icons.check_circle
: Icons.radio_button_unchecked,
size: 14,
color: task.completed ? Colors.green : Colors.blue,
),
const SizedBox(width: 4),
Text(
task.completed ? 'Completed' : 'Mark Complete',
style: TextStyle(
fontSize: 12,
color: task.completed ? Colors.green : Colors.blue,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
const Spacer(),
// Delete button
IconButton(
onPressed: onDelete,
icon: Icon(
Icons.delete_outline,
color: Colors.grey.shade500,
size: 20,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
],
),
),
),
);
}
}
// Extension for TaskPriority UI properties
extension TaskPriorityUI on TaskPriority {
Color get color {
switch (this) {
case TaskPriority.low:
return Colors.green;
case TaskPriority.medium:
return Colors.orange;
case TaskPriority.high:
return Colors.red;
}
}
String get text {
switch (this) {
case TaskPriority.low:
return 'Low';
case TaskPriority.medium:
return 'Medium';
case TaskPriority.high:
return 'High';
}
}
IconData get icon {
switch (this) {
case TaskPriority.low:
return Icons.arrow_downward;
case TaskPriority.medium:
return Icons.remove;
case TaskPriority.high:
return Icons.arrow_upward;
}
}
}
// widgets/loading_shimmer.dart
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
class TaskLoadingShimmer extends StatelessWidget {
const TaskLoadingShimmer({super.key});
@override
Widget build(BuildContext context) {
return Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 6,
itemBuilder: (context, index) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Priority shimmer
Container(
width: 60,
height: 20,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 16),
// Title shimmer
Container(
width: double.infinity,
height: 16,
color: Colors.white,
),
const SizedBox(height: 8),
Container(
width: MediaQuery.of(context).size.width * 0.7,
height: 16,
color: Colors.white,
),
const SizedBox(height: 16),
// Bottom row shimmer
Row(
children: [
Container(
width: 100,
height: 20,
color: Colors.white,
),
const Spacer(),
Container(
width: 60,
height: 20,
color: Colors.white,
),
],
),
],
),
),
);
},
),
);
}
}
// widgets/empty_state.dart
import 'package:flutter/material.dart';
class EmptyStateWidget extends StatelessWidget {
final String title;
final String description;
final IconData icon;
final VoidCallback? onAction;
final String actionText;
const EmptyStateWidget({
super.key,
required this.title,
required this.description,
this.icon = Icons.task_alt,
this.onAction,
this.actionText = 'Add Task',
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 48,
color: Colors.blue,
),
),
const SizedBox(height: 24),
Text(
title,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
description,
style: const TextStyle(
fontSize: 16,
color: Colors.grey,
),
textAlign: TextAlign.center,
),
if (onAction != null) ...[
const SizedBox(height: 24),
ElevatedButton(
onPressed: onAction,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(actionText),
),
],
],
),
),
);
}
}
Step 5: Building the Main Tasks Screen
Now let’s create our beautiful main screen:
// screens/tasks_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:liquid_pull_to_refresh/liquid_pull_to_refresh.dart';
import '../models/task.dart';
import '../providers/task_providers.dart';
import '../widgets/task_card.dart';
import '../widgets/loading_shimmer.dart';
import '../widgets/empty_state.dart';
import '../widgets/error_widget.dart';
class TasksScreen extends ConsumerStatefulWidget {
const TasksScreen({super.key});
@override
ConsumerState<TasksScreen> createState() => _TasksScreenState();
}
class _TasksScreenState extends ConsumerState<TasksScreen> {
final _scrollController = ScrollController();
final _searchController = TextEditingController();
bool _showSearchBar = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels ==
_scrollController.position.maxScrollExtent) {
_loadMore();
}
}
Future<void> _loadMore() async {
if (!ref.read(tasksProvider).hasReachedMax &&
!ref.read(tasksProvider).isLoadingMore) {
// JSONPlaceholder doesn't support pagination, but we keep the pattern
// for educational purposes
}
}
Future<void> _refresh() async {
await ref.read(tasksProvider.notifier).refreshTasks();
}
void _toggleTaskCompletion(Task task) {
ref.read(tasksProvider.notifier).toggleTaskCompletion(task);
}
void _deleteTask(Task task) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete Task'),
content: const Text('Are you sure you want to delete this task?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref.read(tasksProvider.notifier).deleteTask(task.id);
Navigator.pop(context);
// Show snackbar
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Task deleted successfully'),
duration: Duration(seconds: 2),
),
);
},
child: const Text(
'Delete',
style: TextStyle(color: Colors.red),
),
),
],
);
},
);
}
void _showAddTaskDialog() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(24),
),
),
builder: (context) {
return const AddTaskBottomSheet();
},
);
}
void _showFilterDialog() {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(24),
),
),
builder: (context) {
return const FilterBottomSheet();
},
);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(tasksProvider);
final stats = ref.watch(taskStatsProvider);
return Scaffold(
backgroundColor: Colors.grey.shade50,
body: SafeArea(
child: Column(
children: [
// App bar
_buildAppBar(stats),
// Search bar (conditional)
if (_showSearchBar) _buildSearchBar(),
// Stats cards
_buildStatsCards(stats),
// Tasks list
Expanded(
child: LiquidPullToRefresh(
onRefresh: _refresh,
color: Colors.blue,
backgroundColor: Colors.white,
height: 150,
springAnimationDurationInMilliseconds: 500,
child: _buildTaskList(state),
),
),
],
),
),
// Add task FAB
floatingActionButton: FloatingActionButton(
onPressed: _showAddTaskDialog,
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.add),
),
);
}
Widget _buildAppBar(TaskStats stats) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Title and stats
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'TaskFlow',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 4),
Text(
'${stats.completed} of ${stats.total} tasks completed',
style: const TextStyle(
fontSize: 14,
color: Colors.grey,
),
),
],
),
),
// Actions
Row(
children: [
IconButton(
onPressed: () {
setState(() {
_showSearchBar = !_showSearchBar;
if (!_showSearchBar) {
_searchController.clear();
ref.read(tasksProvider.notifier).setSearchQuery('');
}
});
},
icon: Icon(
_showSearchBar ? Icons.close : Icons.search,
color: Colors.grey.shade700,
),
),
IconButton(
onPressed: _showFilterDialog,
icon: Icon(
Icons.filter_list,
color: Colors.grey.shade700,
),
),
],
),
],
),
);
}
Widget _buildSearchBar() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
controller: _searchController,
onChanged: (value) {
ref.read(tasksProvider.notifier).setSearchQuery(value);
},
decoration: InputDecoration(
hintText: 'Search tasks...',
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
ref.read(tasksProvider.notifier).setSearchQuery('');
},
),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 0,
),
),
),
);
}
Widget _buildStatsCards(TaskStats stats) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
// Total tasks card
_buildStatCard(
value: stats.total.toString(),
label: 'Total Tasks',
icon: Icons.task,
color: Colors.blue,
),
const SizedBox(width: 12),
// Completed tasks card
_buildStatCard(
value: stats.completed.toString(),
label: 'Completed',
icon: Icons.check_circle,
color: Colors.green,
),
const SizedBox(width: 12),
// Pending tasks card
_buildStatCard(
value: stats.pending.toString(),
label: 'Pending',
icon: Icons.pending,
color: Colors.orange,
),
const SizedBox(width: 12),
// Completion rate card
_buildStatCard(
value: '${stats.completionRate}%',
label: 'Completion Rate',
icon: Icons.trending_up,
color: Colors.purple,
),
],
),
);
}
Widget _buildStatCard({
required String value,
required String label,
required IconData icon,
required Color color,
}) {
return Container(
width: 140,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, color: color, size: 20),
),
const SizedBox(height: 12),
Text(
value,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
],
),
);
}
Widget _buildTaskList(TaskState state) {
return state.tasks.when(
data: (tasks) {
if (tasks.isEmpty) {
return EmptyStateWidget(
title: 'No Tasks Found',
description: _getEmptyStateMessage(state),
icon: Icons.task_alt,
onAction: _showAddTaskDialog,
actionText: 'Create Your First Task',
);
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: tasks.length + (state.isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index < tasks.length) {
final task = tasks[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TaskCard(
task: task,
onToggle: () => _toggleTaskCompletion(task),
onDelete: () => _deleteTask(task),
onTap: () {
// Navigate to task detail screen
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TaskDetailScreen(task: task),
),
);
},
),
);
} else {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: CircularProgressIndicator(),
),
);
}
},
);
},
loading: () => const TaskLoadingShimmer(),
error: (error, stackTrace) {
return ErrorWidgetWithRetry(
error: error.toString(),
onRetry: _refresh,
);
},
);
}
String _getEmptyStateMessage(TaskState state) {
if (state.searchQuery != null && state.searchQuery!.isNotEmpty) {
return 'No tasks found for "${state.searchQuery}". Try a different search term.';
}
if (state.filterPriority != null || state.filterCategory != null) {
return 'No tasks match your current filters. Try adjusting them.';
}
return 'Start organizing your tasks by creating your first one!';
}
}
class AddTaskBottomSheet extends ConsumerStatefulWidget {
const AddTaskBottomSheet({super.key});
@override
ConsumerState<AddTaskBottomSheet> createState() => _AddTaskBottomSheetState();
}
class _AddTaskBottomSheetState extends ConsumerState<AddTaskBottomSheet> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
TaskPriority _selectedPriority = TaskPriority.medium;
String? _selectedCategory;
final List<String> categories = ['Work', 'Personal', 'Shopping', 'Health', 'Finance'];
@override
void dispose() {
_titleController.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState!.validate()) {
final title = _titleController.text.trim();
ref.read(tasksProvider.notifier).addTask(
title,
_selectedPriority,
_selectedCategory ?? categories[0],
);
Navigator.pop(context);
// Show success message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Task added successfully!'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Add New Task',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Task title
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Task Title',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
filled: true,
fillColor: Colors.white,
),
maxLines: 2,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter a task title';
}
return null;
},
),
const SizedBox(height: 16),
// Priority selection
const Text(
'Priority',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Row(
children: TaskPriority.values.map((priority) {
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ChoiceChip(
label: Text(priority.text),
selected: _selectedPriority == priority,
selectedColor: priority.color.withOpacity(0.2),
labelStyle: TextStyle(
color: _selectedPriority == priority
? priority.color
: Colors.grey,
fontWeight: FontWeight.w500,
),
onSelected: (selected) {
setState(() {
_selectedPriority = priority;
});
},
),
),
);
}).toList(),
),
const SizedBox(height: 16),
// Category selection
const Text(
'Category',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: _selectedCategory ?? categories[0],
items: categories.map((category) {
return DropdownMenuItem(
value: category,
child: Text(category),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedCategory = value;
});
},
decoration: const InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
filled: true,
fillColor: Colors.white,
),
),
const SizedBox(height: 24),
// Action buttons
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text('Add Task'),
),
),
],
),
],
),
),
),
);
}
}
class FilterBottomSheet extends ConsumerWidget {
const FilterBottomSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(tasksProvider);
final categories = ['Work', 'Personal', 'Shopping', 'Health', 'Finance'];
return Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Filter Tasks',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Priority filter
const Text(
'Priority',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Row(
children: [
// All priorities
ChoiceChip(
label: const Text('All'),
selected: state.filterPriority == null,
onSelected: (_) {
ref.read(tasksProvider.notifier).setPriorityFilter(null);
},
),
const SizedBox(width: 8),
// Priority options
...TaskPriority.values.map((priority) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ChoiceChip(
label: Text(priority.text),
selected: state.filterPriority == priority,
selectedColor: priority.color.withOpacity(0.2),
labelStyle: TextStyle(
color: state.filterPriority == priority
? priority.color
: Colors.grey,
fontWeight: FontWeight.w500,
),
onSelected: (_) {
ref.read(tasksProvider.notifier).setPriorityFilter(priority);
},
),
);
}),
],
),
const SizedBox(height: 24),
// Category filter
const Text(
'Category',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
// All categories
ChoiceChip(
label: const Text('All'),
selected: state.filterCategory == null,
onSelected: (_) {
ref.read(tasksProvider.notifier).setCategoryFilter(null);
},
),
// Category options
...categories.map((category) {
return ChoiceChip(
label: Text(category),
selected: state.filterCategory == category,
onSelected: (_) {
ref.read(tasksProvider.notifier).setCategoryFilter(category);
},
);
}),
],
),
const SizedBox(height: 32),
// Clear filters button
OutlinedButton(
onPressed: () {
ref.read(tasksProvider.notifier).setPriorityFilter(null);
ref.read(tasksProvider.notifier).setCategoryFilter(null);
Navigator.pop(context);
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text('Clear All Filters'),
),
],
),
);
}
}
Step 6: App Configuration
// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
// app.dart
import 'package:flutter/material.dart';
import 'screens/tasks_screen.dart';
import 'core/theme/app_theme.dart';
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TaskFlow',
theme: AppTheme.buildAppTheme(),
debugShowCheckedModeBanner: false,
home: const TasksScreen(),
);
}
}
// core/theme/app_theme.dart
import 'package:flutter/material.dart';
class AppTheme {
static ThemeData buildAppTheme() {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF6750A4),
brightness: Brightness.light,
),
fontFamily: 'Inter',
appBarTheme: const AppBarTheme(
elevation: 0,
scrolledUnderElevation: 2,
backgroundColor: Colors.transparent,
foregroundColor: Colors.black87,
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6750A4),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: Color(0xFF6750A4),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
),
);
}
}
Step 7: Task Detail Screen (Bonus)
// screens/task_detail_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/task.dart';
class TaskDetailScreen extends ConsumerWidget {
final Task task;
const TaskDetailScreen({
super.key,
required this.task,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
backgroundColor: Colors.grey.shade50,
body: CustomScrollView(
slivers: [
// App bar with background
SliverAppBar(
expandedHeight: 200,
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
task.priority?.color.withOpacity(0.3) ?? Colors.blue,
Colors.white,
],
),
),
),
),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.arrow_back),
),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.edit),
),
onPressed: () {
// Edit task functionality
},
),
],
),
// Task content
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Priority badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: task.priority?.color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: task.priority?.color.withOpacity(0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
task.priority?.icon,
size: 14,
color: task.priority?.color,
),
const SizedBox(width: 4),
Text(
task.priority?.text ?? 'Medium Priority',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: task.priority?.color,
),
),
],
),
),
const SizedBox(height: 16),
// Task title
Text(
task.title,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 24),
// Details card
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Status
_buildDetailRow(
icon: Icons.circle,
label: 'Status',
value: task.completed ? 'Completed' : 'Pending',
iconColor: task.completed ? Colors.green : Colors.orange,
),
const SizedBox(height: 16),
// Category
if (task.category != null)
_buildDetailRow(
icon: Icons.category,
label: 'Category',
value: task.category!,
iconColor: Colors.blue,
),
if (task.category != null) const SizedBox(height: 16),
// Due date
_buildDetailRow(
icon: Icons.calendar_today,
label: 'Due Date',
value: task.formattedDueDate,
iconColor: Colors.purple,
),
const SizedBox(height: 16),
// Task ID
_buildDetailRow(
icon: Icons.numbers,
label: 'Task ID',
value: task.displayId,
iconColor: Colors.grey,
),
const SizedBox(height: 16),
// Created date
_buildDetailRow(
icon: Icons.access_time,
label: 'Created',
value: _formatDate(task.createdAt),
iconColor: Colors.grey,
),
],
),
),
),
const SizedBox(height: 24),
// Notes section
if (task.notes != null && task.notes!.isNotEmpty) ...[
const Text(
'Notes',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Text(
task.notes!,
style: const TextStyle(
fontSize: 16,
height: 1.5,
color: Colors.black87,
),
),
),
),
],
const SizedBox(height: 40),
],
),
),
),
],
),
);
}
Widget _buildDetailRow({
required IconData icon,
required String label,
required String value,
required Color iconColor,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: iconColor, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
],
),
),
],
);
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) return 'Today';
if (difference.inDays == 1) return 'Yesterday';
if (difference.inDays < 7) return '${difference.inDays} days ago';
if (difference.inDays < 30) return '${(difference.inDays / 7).round()} weeks ago';
return '${date.day}/${date.month}/${date.year}';
}
}
Key Features Implemented
- Real API Integration: Connected to JSONPlaceholder API
- Beautiful Material 3 Design: Modern, clean UI with proper theming
- Advanced State Management: Riverpod with proper error handling and loading states
- Search and Filtering: Real-time search with debouncing
- Pull-to-Refresh: Smooth refresh animation
- Empty States: Beautiful empty state illustrations
- Error Handling: Graceful error states with retry functionality
- Statistics Dashboard: Visual task completion metrics
- Responsive Design: Works on phones and tablets
- Animations: Smooth transitions and loading effects
Running the App
- Add the dependencies to your
pubspec.yaml - Run
flutter pub get - Generate Freezed files:
flutter pub run build_runner build - Run the app:
flutter run
Conclusion
You’ve now built a complete, production-ready Task Manager app with:
- Real API integration using JSONPlaceholder
- Beautiful, responsive UI with Material 3
- Robust state management with Riverpod
- Comprehensive error handling
- Advanced features like search, filtering, and statistics
This app demonstrates best practices for API consumption in Flutter and shows how Riverpod makes state management clean and maintainable. The patterns shown here can be adapted to any API-driven Flutter application.
Next Steps You Could Implement:
- Add user authentication
- Implement local database for offline support
- Add push notifications for due dates
- Create a calendar view
- Add team collaboration features
- Implement drag-and-drop for task organization
Happy coding!

























Leave a Comment
Your email address will not be published. Required fields are marked with *