Beginner's Tutorial: Build a Task Manager
In this step-by-step tutorial, we will build a complete Task Manager application in Flutter using vm_result.
We will cover:
- Fetching a list of tasks (Standard Async Fetching).
- Adding a task immediately with a rollback fallback (Optimistic UI updates).
- Displaying notification messages when tasks are added or fail (One-Shot UI Effects).
The Setup
1. The Task Model
Create a file task.model.dart:
class Task {
const Task({required this.id, required this.title, required this.isCompleted});
final String id;
final String title;
final bool isCompleted;
Task copyWith({String? id, String? title, bool? isCompleted}) {
return Task(
id: id ?? this.id,
title: title ?? this.title,
isCompleted: isCompleted ?? this.isCompleted,
);
}
}
The Business Logic
2. Defining UI Side Effects
We want to notify the user via a snackbar when a task is successfully saved or fails. Let's declare our UI effects:
import 'package:vm_result/vm_result.dart';
sealed class TaskUiEffect extends BaseUiEffect {
const TaskUiEffect();
}
class ShowInfoSnackbar extends TaskUiEffect {
const ShowInfoSnackbar(this.message);
final String message;
}
class ShowErrorSnackbar extends TaskUiEffect {
const ShowErrorSnackbar(this.error);
final String error;
}
3. Creating the TaskViewModel
Our ViewModel will extend VMResultEffect to manage a list of tasks (List<Task>) and emit TaskUiEffect side effects.
Create a file task_view_model.dart:
import 'package:uuid/uuid.dart';
import 'package:vm_result/vm_result.dart';
import 'task.model.dart';
import 'task_effects.dart';
class TaskViewModel extends VMResultEffect<List<Task>, TaskUiEffect> {
// Initialize with an empty list
TaskViewModel() : super(const Result.initial());
// Standard fetch showing loading spinner
Future<void> fetchTasks() {
return run(() async {
await Future<void>.delayed(const Duration(seconds: 1.5)); // simulate delay
return [
const Task(id: '1', title: 'Buy groceries', isCompleted: false),
const Task(id: '2', title: 'Write documentation', isCompleted: true),
];
});
}
// Optimistic add task: adds locally immediately, rolls back on error
Future<void> addTask(String title) {
final currentTasks = state.value ?? [];
// 1. Generate local optimistic task
final newTask = Task(
id: const Uuid().v4(),
title: title,
isCompleted: false,
);
final optimisticList = [...currentTasks, newTask];
// 2. Wrap server request in runOptimistic
return runOptimistic(
optimisticState: optimisticList,
action: () async {
await Future<void>.delayed(const Duration(seconds: 1)); // simulate server latency
// Simulate occasional API failure for titles containing "fail"
if (title.toLowerCase().contains('fail')) {
throw Exception('Server rejected task "$title".');
}
emitEffect(ShowInfoSnackbar('Task "$title" added successfully!'));
return optimisticList; // Confirming the change
},
);
}
}
The View Layer
4. Creating the User Interface
Now, we bind the ViewModel to our widget tree. We will use:
ResultBuilder<List<Task>>to rebuild the tasks list widget based on state changes.EffectListenerto capture snackbars without interrupting the list state.
Create a file task_screen.dart:
import 'package:flutter/material.dart';
import 'package:vm_result/vm_result.dart';
import 'task.model.dart';
import 'task_view_model.dart';
import 'task_effects.dart';
class TaskScreen extends StatefulWidget {
const TaskScreen({super.key});
State<TaskScreen> createState() => _TaskScreenState();
}
class _TaskScreenState extends State<TaskScreen> {
late final TaskViewModel _viewModel;
late final TextEditingController _inputController;
void initState() {
super.initState();
_viewModel = TaskViewModel();
_inputController = TextEditingController();
_viewModel.fetchTasks(); // Fetch tasks on startup
}
void dispose() {
_viewModel.dispose();
_inputController.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Reactive Task Manager')),
body: EffectListener<TaskViewModel, List<Task>, TaskUiEffect>(
vm: _viewModel,
listener: (context, effect) {
switch (effect) {
case ShowInfoSnackbar(:final message):
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.green,
),
);
case ShowErrorSnackbar(:final error):
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(error),
backgroundColor: Colors.red,
),
);
}
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// 1. Task List Widget Area
Expanded(
child: ResultBuilder<List<Task>>(
listenable: _viewModel,
builder: (context, state, child) {
return state.when(
initial: () => const Center(child: Text('Press refresh')),
loading: () => const Center(child: CircularProgressIndicator()),
data: (tasks) {
if (tasks.isEmpty) {
return const Center(child: Text('All caught up!'));
}
return ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return ListTile(
leading: Checkbox(
value: task.isCompleted,
onChanged: (_) {},
),
title: Text(task.title),
);
},
);
},
error: (exception) => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: $exception', style: const TextStyle(color: Colors.red)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _viewModel.fetchTasks,
child: const Text('Retry Fetch'),
),
],
),
);
},
),
),
const Divider(),
// 2. Input Box Area
Row(
children: [
Expanded(
child: TextField(
controller: _inputController,
decoration: const InputDecoration(
hintText: 'Enter new task (type "fail" to trigger rollback)',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
onPressed: () {
final title = _inputController.text.trim();
if (title.isNotEmpty) {
_viewModel.addTask(title);
_inputController.clear();
}
},
)
],
),
],
),
),
),
);
}
}
5. Try it Out!
- Compile and run the app.
- The list will show a loading spinner for
1.5 seconds, then render the two initial tasks. - Type a new task name (e.g. "Buy milk") and tap the send icon. Notice the task instantly appends to the list (thanks to
runOptimistic) before the API delay completes. - Now type "fail task" and tap send. The task will instantly append. After 1 second, the API will fail, the task will automatically disappear from the list, and a red error snackbar will display.
This is the power of combining optimistic UI updates with automated rollback guards!