close

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)