DEV Community

Samuel Adekunle
Samuel Adekunle

Posted on • Originally published at techwithsam.dev

Dart Frog Part 3: Connecting Flutter to Your Dart Backend (Full-Stack Todo Demo) 🐸

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’,
            },
          );
        },
      );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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’],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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(),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
            },
          ),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

App screenshots

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)