Hey guys! Welcome to Part 3 of our Dart Frog series. If you missed Part 1 and Part 2, we set up Dart Frog and built a CRUD API for our Task App with hot reload. Watch it now if youβre new!
Today is the payoff: Connecting a Flutter frontend to our Dart Frog server for true full-stack Dartβno Firebase, no Supabaseβββjust pure Dart magic.
Weβll build a Todo/Task app that allows users to create, read, update, and delete tasks. All talking to our local server.
Planning & Setup
Quick plan: Reuse the Todo API from Part 2. In Flutter:
Dio for HTTP (better interceptors, error handling than http package)
Riverpod for state (AsyncNotifierProvider for API calls)
Simple UI: ListView + forms
First, fix CORS in Dart Frogβββadd middleware. Open routes/todos:
Create a new Dart file inside the todos folder _middleware.dart
import βpackage:dart_frog/dart_frog.dartβ;
Handler middleware(Handler handler) {
return handler.use(requestLogger()).use(
(handler) => (context) async {
final response = await handler(context);
return response.copyWith(
headers: {
βAccess-Control-Allow-Originβ: β*β,
βAccess-Control-Allow-Methodsβ: βGET, POST, PUT, DELETE, OPTIONSβ,
βAccess-Control-Allow-Headersβ: βContent-Typeβ,
},
);
},
);
}
For local testing: Use http://10.0.2.2:8080 on Android emulator (or your machine IP on a physical device, e.g., http://localhost:8080).
Start Dart Frog dev server in one window: dart_frog dev.
New Flutter project: flutter create todo_flutter.
pubspec.yaml:
dependencies:
flutter_riverpod: ^3.2.0
uuid: ^4.5.2
dio: ^5.9.0
Model (shareable later): lib/models/todo.dart
class Todo {
final String id;
final String title;
final bool completed;
Todo({required this.id, required this.title, this.completed = false});
Todo copyWith({String? id, String? title, bool? completed}) {
return Todo(
id: id ?? this.id,
title: title ?? this.title,
completed: completed ?? this.completed,
);
}
Map<String, dynamic> toJson() {
return {βidβ: id, βtitleβ: title, βisCompletedβ: completed};
}
factory Todo.fromJson(Map<String, dynamic> json) {
return Todo(
id: json[βidβ],
title: json[βtitleβ],
completed: json[βisCompletedβ],
);
}
}
Dio setup: lib/services/api_service.dart
import βdart:ioβ;
import βpackage:dio/dio.dartβ;
import βpackage:flutter_todo_dart_frog/models/todo.dartβ;
class ApiService {
final Dio _dio = Dio(
BaseOptions(
baseUrl: Platform.isAndroid
? βhttp://10.0.2.2:8080β
: βhttp://localhost:8080β,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
),
);
Future<List<Todo>> getTodos() async {
try {
final response = await _dio.get(β/todosβ);
if (response.data is List) {
final todos = (response.data as List)
.map((todoJson) => Todo.fromJson(todoJson as Map<String, dynamic>))
.toList();
return todos;
}
return [];
} catch (e) {
rethrow;
}
}
Future<void> createTodo(Todo todo) async {
try {
await _dio.post(β/todosβ, data: todo.toJson());
} catch (e) {
rethrow;
}
}
Future<void> updateTodo(Todo todo) async {
try {
await _dio.put(β/todos/${todo.id}β, data: todo.toJson());
} catch (e) {
rethrow;
}
}
Future<void> deleteTodo(String id) async {
try {
await _dio.delete(β/todos/$idβ);
} catch (e) {
rethrow;
}
}
}
Riverpod providers: lib/providers/todo_provider.dart
import βpackage:flutter_riverpod/flutter_riverpod.dartβ;
import βpackage:flutter_todo_dart_frog/models/todo.dartβ;
import βpackage:flutter_todo_dart_frog/services/api_service.dartβ;
import βpackage:uuid/uuid.dartβ;
final apiServiceProvider = Provider((ref) => ApiService());
final todoListProvider = AsyncNotifierProvider<TodoListNotifier, List<Todo>>(
() {
return TodoListNotifier();
},
);
class TodoListNotifier extends AsyncNotifier<List<Todo>> {
@override
Future<List<Todo>> build() async {
return ref.watch(apiServiceProvider).getTodos();
}
Future<void> addTodo(String title) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final newTodo = Todo(
id: const Uuid().v4(),
title: title,
completed: false,
);
await ref.read(apiServiceProvider).createTodo(newTodo);
final todos = await ref.read(apiServiceProvider).getTodos();
return todos;
});
}
Future<void> toggleTodo(Todo todo) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final updatedTodo = todo.copyWith(completed: !todo.completed);
await ref.read(apiServiceProvider).updateTodo(updatedTodo);
final todos = await ref.read(apiServiceProvider).getTodos();
return todos;
});
}
Future<void> deleteTodo(String id) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(apiServiceProvider).deleteTodo(id);
final todos = await ref.read(apiServiceProvider).getTodos();
return todos;
});
}
}
lib/main.dart (add google_fonts package):
import βpackage:flutter/material.dartβ;
import βpackage:flutter_riverpod/flutter_riverpod.dartβ;
import βpackage:google_fonts/google_fonts.dartβ;
import βhome.dartβ;
void main() {
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: βPremium Todoβ,
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF6750A4),
brightness: Brightness.light,
),
textTheme: GoogleFonts.outfitTextTheme(),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFD0BCFF),
brightness: Brightness.dark,
),
textTheme: GoogleFonts.outfitTextTheme(ThemeData.dark().textTheme),
),
home: const TodoListScreen(),
);
}
}
lib/home.dart:
import βpackage:flutter/material.dartβ;
import βpackage:flutter_riverpod/flutter_riverpod.dartβ;
import βmodels/todo.dartβ;
import βproviders/todo_provider.dartβ;
class TodoListScreen extends ConsumerWidget {
const TodoListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todosAsync = ref.watch(todoListProvider);
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar.large(
title: const Text(βMy Tasksβ),
centerTitle: false,
floating: true,
pinned: true,
actions: [
IconButton(
onPressed: () => ref.refresh(todoListProvider),
icon: const Icon(Icons.refresh),
),
],
),
todosAsync.when(
data: (todos) => sliverTodoList(todos, ref, context),
loading: () => const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
error: (err, stack) =>
SliverFillRemaining(child: Center(child: Text(βError: $errβ))),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showAddTodoDialog(context, ref),
label: const Text(βAdd Taskβ),
icon: const Icon(Icons.add),
),
);
}
Widget sliverTodoList(List<Todo> todos, WidgetRef ref, BuildContext context) {
if (todos.isEmpty) {
return const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.task_alt, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(
βNo tasks yet. Add one!β,
style: TextStyle(fontSize: 18, color: Colors.grey),
),
],
),
),
);
}
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final todo = todos[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TodoCard(todo: todo),
);
}, childCount: todos.length),
),
);
}
void _showAddTodoDialog(BuildContext context, WidgetRef ref) {
final controller = TextEditingController();
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 20,
right: 20,
top: 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(βNew Taskβ, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
hintText: βWhat needs to be done?β,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
if (controller.text.isNotEmpty) {
ref.read(todoListProvider.notifier).addTodo(controller.text);
Navigator.pop(context);
}
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(βCreate Taskβ),
),
const SizedBox(height: 20),
],
),
),
);
}
}
class TodoCard extends ConsumerWidget {
final Todo todo;
const TodoCard({super.key, required this.todo});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Dismissible(
key: Key(todo.id.toString()),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: Colors.red.shade100,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.delete, color: Colors.red),
),
onDismissed: (_) {
ref.read(todoListProvider.notifier).deleteTodo(todo.id);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 8,
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.completed ? TextDecoration.lineThrough : null,
color: todo.completed ? Colors.grey : null,
fontWeight: FontWeight.w500,
),
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
onPressed: () {
ref.read(todoListProvider.notifier).deleteTodo(todo.id);
},
),
leading: Checkbox(
value: todo.completed,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
onChanged: (_) {
ref.read(todoListProvider.notifier).toggleTodo(todo);
},
),
),
),
);
}
}
Source Code πβββShow some β€οΈ by starring β the repo and follow me π! https://github.com/techwithsam/dart_frog_full_course_tutorial
This is your first real full-stack project with pure Dart and Flutterβββcongrats! Next part: Authentication (JWT) and deployment with Dart Globe.
Samuel Adekunle, Tech With Sam YouTube

Top comments (0)