Flutter Expense Tracker with FastAPI

Build a full-stack expense tracker with Flutter, FastAPI, and Docker.

Introduction

30 Second Summary

Every time you tap your card or split a bill, you are generating expense data. Real-world apps track that data by sending it from your phone to a server, which stores it in a database for safekeeping.

In this project, you will build a personal expense tracker that connects a Flutter mobile app to a FastAPI backend running inside Docker containers. You will add, view, and delete expenses while seeing how all layers of a modern full-stack architecture fit together.

What You'll Build

A full-stack expense tracker where a Flutter Android app communicates with a containerized Python REST API that persists data in PostgreSQL.

By the end of this project, you'll have:

  • A FastAPI backend with full CRUD endpoints and auto-generated Swagger documentation.
  • A Docker Compose configuration orchestrating the API and PostgreSQL in containers.
  • A Flutter Android app with a clean UI for adding, viewing, and deleting expenses over HTTP.
  • Secret Mission: Add category filtering and a spending summary endpoint that returns total spending per category.

Are there any prerequisites?

This project assumes you have advanced Flutter and Dart knowledge, basic Python familiarity, and basic command line experience. You should also have an Android device available for testing the Flutter app.

Before We Start

Before We Start...

Set Up Docker Desktop and Project Structure

In this project, you are building a full-stack expense tracker with a Flutter frontend, a FastAPI backend, and a PostgreSQL database. All three layers need to run together and talk to each other.

To make this work without manually installing Python, PostgreSQL, and all their dependencies on your machine, you will use Docker. Docker packages your backend and database into isolated containers that run identically everywhere. In this step, you will install Docker Desktop and create the project folder structure.

In this step, get ready to:
  • Install Docker Desktop and verify it works.
  • Create the project folder structure for backend and frontend.
  • Initialize a Flutter project inside the frontend directory.
Install Docker Desktop

Docker Desktop is an application that lets you build and run containers on your local machine. It bundles the Docker Engine, the Docker Compose tool, and a graphical interface for managing containers.

  • Check if Docker is already installed by opening a terminal and running these commands:
docker --version
docker compose version

✔️ I see version information

Docker is ready to go. Both commands printed a version number, which means Docker Desktop is already installed and running on your machine.

Skip ahead to the next substep.

ⓧ Command not found

Docker is not installed yet. Follow the instructions below for your operating system.

Windows

  • Download Docker Desktop from the official install page:
  • https://docs.docker.com/desktop/setup/install/windows-install/
  • Run the installer and select the per-user installation option (recommended, no admin rights needed).
  • The installer uses the WSL 2 backend by default. Accept the defaults and let it complete.
  • Once installation finishes, restart your terminal completely (close and reopen it).
  • Run the version check commands again to confirm Docker is working:
docker --version
docker compose version

Both commands should now return version information without errors.

macOS

  • Download Docker Desktop from the official install page:
  • https://docs.docker.com/desktop/setup/install/mac-install/
  • Double-click the downloaded Docker.dmg file to open the installer.
  • Drag the Docker icon to your Applications folder.
  • Open Docker.app from your Applications folder to start Docker Desktop.
  • Accept the service agreement when prompted. The first launch may take a few minutes for security checks.
  • Open a new terminal window and run the version check commands:
docker --version
docker compose version

Both commands should now return version information without errors.

What is Docker Desktop?

Docker Desktop runs a lightweight Linux virtual machine on your computer. Inside that VM, your backend code and database each get their own isolated container. This means you do not need to install Python or PostgreSQL directly on your machine.

Docker Compose is the tool that orchestrates multiple containers together. You define your services in a single YAML file, and Compose starts them all with one command.

Docker commands still not found?

Make sure Docker Desktop is actually running (look for the whale icon in your system tray or menu bar). The docker commands only work while Docker Desktop is active.

On Windows, ensure WSL 2 is enabled. Open PowerShell as administrator and run wsl --update if you see WSL-related errors.

help me troubleshoot Docker Desktop not being recognized in my terminal

Create the project folder structure

Your project needs a clear separation between the backend (Python API) and the frontend (Flutter app). You will create the full directory tree now and fill in the files in later steps.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and its backend structure by running these commands:
mkdir expense-tracker
cd expense-tracker
mkdir -p backend/app
touch backend/app/__init__.py
touch backend/app/main.py
touch backend/app/models.py
touch backend/app/database.py
touch backend/requirements.txt
touch backend/Dockerfile
touch docker-compose.yml

What does this structure do?

The backend/app/ folder holds your Python API code. The __init__.py file marks it as a Python package. The Dockerfile tells Docker how to build the backend container.

The docker-compose.yml at the project root orchestrates both the API container and the PostgreSQL database container together.

  • Confirm the files were created by running:
ls backend/app/
ls backend/

You should see __init__.py, main.py, models.py, and database.py in the first output. The second output should show app/, Dockerfile, and requirements.txt.

Don't see the files listed?

Make sure you ran the commands from inside the expense-tracker folder. Run pwd to check your current directory. It should end with expense-tracker.

On Windows, if touch is not recognized, open Git Bash instead of PowerShell. Git Bash supports Unix commands like mkdir -p and touch.

help me create the project folder structure for my expense tracker

Initialize the Flutter project

The frontend/ directory will hold your Flutter app. Instead of creating it manually, you will use Flutter's scaffolding tool to generate a fresh project with all the boilerplate.

  • Make sure you are inside the expense-tracker folder, then create the Flutter project by running:
flutter create frontend

What does flutter create do?

This command generates a complete Flutter project inside a new frontend/ directory. It includes the lib/ folder for your Dart code, a pubspec.yaml for dependencies, and platform-specific folders for Android and iOS builds.

You should see Flutter print a series of "Creating project" messages followed by "All done" when it finishes.

  • Confirm the Flutter project was created by running:
ls frontend/

You should see folders like lib/, android/, test/, and files like pubspec.yaml in the output.

  • Open the entire expense-tracker folder in VS Code by running:
code .

Why open the root folder?

Opening expense-tracker/ (not just backend/ or frontend/) gives you visibility into both sides of the stack from a single VS Code window. You can edit backend Python files and frontend Dart files without switching workspaces.

VS Code not opening from the terminal?

If code . is not recognized, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, type VS Code, and press Enter. Then go to File > Open Folder and select the expense-tracker folder on your Desktop.

help me open my expense-tracker project folder in VS Code

Your development environment is ready. You have Docker Desktop running, a backend folder structure waiting for Python code, and a fresh Flutter project in the frontend. Next up, you will build the FastAPI backend that powers your expense tracker.

Build the FastAPI Backend

Your project structure is in place with empty backend files ready to be filled. Now it is time to write the actual API that your Flutter app will talk to.

In this step, you will build a complete REST API using FastAPI and SQLModel. The API will handle creating, reading, and deleting expenses, persisting them in PostgreSQL through a clean ORM layer.

In this step, get ready to:
  • Define SQLModel data models for expenses.
  • Create CRUD endpoints that read and write to the database.
  • Pin the Python dependencies in a requirements file.
Define the data models

SQLModel lets you define one Python class that serves as both the database table schema and the API validation schema. This means less boilerplate compared to defining separate SQLAlchemy models and Pydantic schemas.

  • In VS Code, open backend/app/models.py and paste the following code:
from datetime import datetime
from sqlmodel import SQLModel, Field


class ExpenseBase(SQLModel):
    # Shared fields for all expense models
    title: str
    amount: float
    category: str


class Expense(ExpenseBase, table=True):
    # The actual database table - inherits title, amount, category
    id: int | None = Field(default=None, primary_key=True)
    created_at: datetime = Field(default_factory=datetime.utcnow)

What does this code do?

ExpenseBase defines the three fields every expense needs: a title, an amount, and a category. It does not map to a database table on its own.

Expense inherits those fields and adds table=True, which tells SQLModel to create an actual PostgreSQL table. The id is auto-generated by the database, and created_at records the timestamp automatically when a new expense is created.

Now add the API schema classes below the Expense class in the same file:

class ExpenseCreate(ExpenseBase):
    # Used for incoming POST requests - no id or timestamp needed
    pass


class ExpenseRead(ExpenseBase):
    # Used for API responses - includes id and timestamp
    id: int
    created_at: datetime

Why separate Create and Read models?

ExpenseCreate defines what a client sends when creating an expense. It only needs title, amount, and category.

ExpenseRead defines what the API sends back. It includes the id and created_at that the database generates. FastAPI uses these models to automatically validate requests and serialize responses.

  • Save backend/app/models.py.

Seeing a red underline on imports?

VS Code may show import errors for sqlmodel because the package is not installed on your local machine. This is expected. The code will run inside a Docker container where the dependencies are installed.

help me understand why VS Code shows import errors for packages that will run in Docker

✔️ Awesome, I've got everything!

Great. Double check you have saved models.py with all four classes.

ⓧ I'd like to double check the full code

from datetime import datetime
from sqlmodel import SQLModel, Field


class ExpenseBase(SQLModel):
    title: str
    amount: float
    category: str


class Expense(ExpenseBase, table=True):
    id: int | None = Field(default=None, primary_key=True)
    created_at: datetime = Field(default_factory=datetime.utcnow)


class ExpenseCreate(ExpenseBase):
    pass


class ExpenseRead(ExpenseBase):
    id: int
    created_at: datetime

Next, you need to configure how the application connects to PostgreSQL.

  • Open backend/app/database.py and paste the following code:
import os
from sqlmodel import SQLModel, Session, create_engine

# Read the database connection string from environment variables
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/expenses")

# Create the database engine with SQL logging enabled
engine = create_engine(DATABASE_URL, echo=True)


def create_db_and_tables():
    # Creates all tables defined by SQLModel classes
    SQLModel.metadata.create_all(engine)


def get_session():
    # Yields a database session for each request
    with Session(engine) as session:
        yield session

What does this code do?

DATABASE_URL reads the connection string from an environment variable. Docker Compose will set this variable when running the container. The fallback value is for local development.

create_db_and_tables() creates the PostgreSQL tables from your SQLModel classes. get_session() provides a fresh database session per request using Python's yield pattern, which ensures the session closes automatically after each request completes.

  • Save backend/app/database.py.

You won't see a visible change yet. Both files will be tested in the next step when Docker runs the containers.

✔️ Awesome, I've got everything!

Great. Double check you have saved database.py with the engine and both functions.

ⓧ I'd like to double check the full code

import os
from sqlmodel import SQLModel, Session, create_engine

DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/expenses")

engine = create_engine(DATABASE_URL, echo=True)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


def get_session():
    with Session(engine) as session:
        yield session
Create the CRUD endpoints

With the models and database layer in place, you can now build the API endpoints. FastAPI uses Python decorators to map functions to HTTP methods and URL paths.

  • Open backend/app/main.py and paste the application setup and the first endpoint:
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, HTTPException
from sqlmodel import Session, select

from .database import create_db_and_tables, get_session
from .models import Expense, ExpenseCreate, ExpenseRead


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Runs once when the app starts - creates database tables
    create_db_and_tables()
    yield


# Initialize the FastAPI app with a title and startup logic
app = FastAPI(title="Expense Tracker API", lifespan=lifespan)


@app.post("/expenses", response_model=ExpenseRead)
def create_expense(expense: ExpenseCreate, session: Session = Depends(get_session)):
    # Convert the incoming data to a database model and save it
    db_expense = Expense.model_validate(expense)
    session.add(db_expense)
    session.commit()
    session.refresh(db_expense)
    return db_expense

What does this code do?

The lifespan function runs once when the app starts. It calls create_db_and_tables() to ensure the PostgreSQL tables exist before any requests arrive.

The create_expense function handles POST requests. FastAPI automatically validates the request body against ExpenseCreate, injects a database session via Depends(get_session), then saves the expense and returns the full record with its new ID.

Now add the remaining three endpoints below the create_expense function in the same file:

@app.get("/expenses", response_model=list[ExpenseRead])
def list_expenses(session: Session = Depends(get_session)):
    # Fetch all expenses from the database
    expenses = session.exec(select(Expense)).all()
    return expenses


@app.get("/expenses/{expense_id}", response_model=ExpenseRead)
def get_expense(expense_id: int, session: Session = Depends(get_session)):
    # Fetch a single expense by ID, or return 404
    expense = session.get(Expense, expense_id)
    if not expense:
        raise HTTPException(status_code=404, detail="Expense not found")
    return expense


@app.delete("/expenses/{expense_id}")
def delete_expense(expense_id: int, session: Session = Depends(get_session)):
    # Delete an expense by ID, or return 404 if it doesn't exist
    expense = session.get(Expense, expense_id)
    if not expense:
        raise HTTPException(status_code=404, detail="Expense not found")
    session.delete(expense)
    session.commit()
    return {"detail": "Expense deleted"}

What do these endpoints do?

  • GET /expenses returns every expense in the database as a list.
  • GET /expenses/{expense_id} fetches one expense by its ID. If it does not exist, the API returns a 404 error.
  • DELETE /expenses/{expense_id} removes an expense from the database and returns a confirmation message.
  • Save backend/app/main.py.

You won't see output yet. The API needs PostgreSQL running to start, which Docker Compose will handle in the next step.

Seeing an error about relative imports?

The from .database and from .models lines use relative imports, which only work when the code runs as a package. Do not try to run main.py directly with python main.py. The Docker container will run it correctly using uvicorn app.main:app.

help me understand why relative imports fail when I run a Python file directly

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py with all four endpoint functions.

ⓧ I'd like to double check the full code

from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, HTTPException
from sqlmodel import Session, select

from .database import create_db_and_tables, get_session
from .models import Expense, ExpenseCreate, ExpenseRead


@asynccontextmanager
async def lifespan(app: FastAPI):
    create_db_and_tables()
    yield


app = FastAPI(title="Expense Tracker API", lifespan=lifespan)


@app.post("/expenses", response_model=ExpenseRead)
def create_expense(expense: ExpenseCreate, session: Session = Depends(get_session)):
    db_expense = Expense.model_validate(expense)
    session.add(db_expense)
    session.commit()
    session.refresh(db_expense)
    return db_expense


@app.get("/expenses", response_model=list[ExpenseRead])
def list_expenses(session: Session = Depends(get_session)):
    expenses = session.exec(select(Expense)).all()
    return expenses


@app.get("/expenses/{expense_id}", response_model=ExpenseRead)
def get_expense(expense_id: int, session: Session = Depends(get_session)):
    expense = session.get(Expense, expense_id)
    if not expense:
        raise HTTPException(status_code=404, detail="Expense not found")
    return expense


@app.delete("/expenses/{expense_id}")
def delete_expense(expense_id: int, session: Session = Depends(get_session)):
    expense = session.get(Expense, expense_id)
    if not expense:
        raise HTTPException(status_code=404, detail="Expense not found")
    session.delete(expense)
    session.commit()
    return {"detail": "Expense deleted"}
Create the requirements file

The last piece is pinning the exact package versions your backend depends on. Docker will use this file to install dependencies inside the container.

  • Open backend/requirements.txt and paste these three dependencies:
fastapi[standard]==0.137.1
sqlmodel==0.0.38
psycopg2-binary==2.9.10

What are these packages?

  • fastapi[standard] installs FastAPI plus uvicorn (the ASGI server) and other standard extras.
  • sqlmodel provides the ORM layer that combines SQLAlchemy and Pydantic into a single model.
  • psycopg2-binary is the PostgreSQL driver that lets Python talk to the database.
  • Save backend/requirements.txt.

Your backend code is complete. The API will serve at http://localhost:8000 with auto-generated docs at http://localhost:8000/docs once it runs inside Docker.

Why SQLModel instead of raw SQLAlchemy?

SQLModel is built by the same author as FastAPI. It combines Pydantic validation with SQLAlchemy database mapping in a single class definition. This means your data models, request validation, and response serialization all live in one place instead of three.

Your backend is written and ready to containerize. Next up, you will wrap this API and a PostgreSQL database into Docker containers so they can run together with a single command.

Containerize with Docker Compose

Your FastAPI backend has all four CRUD endpoints written and ready to go. The problem is that running it requires Python, specific packages, and a PostgreSQL database. That is a lot of manual setup for anyone who wants to run your app.

Docker solves this by packaging your entire application and its dependencies into containers. In this step, you will write a Dockerfile for the backend and a Docker Compose configuration that orchestrates both the API and a PostgreSQL database together.

In this step, get ready to:
  • Write a Dockerfile that packages the FastAPI backend into a container image.
  • Create a Docker Compose file that orchestrates the API and database services.
  • Start the full stack and test it via the Swagger UI.
Write the Dockerfile for the backend

A Dockerfile is a recipe that tells Docker how to build an image for your application. It specifies the base operating system, installs dependencies, copies your code, and defines how to start the app.

  • In VS Code, open the empty backend/Dockerfile file.
  • Paste the following code into it:
FROM python:3.12-slim

WORKDIR /code

# Copy requirements first for better Docker layer caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy the application code
COPY ./app ./app

EXPOSE 8000

# Start the FastAPI server on all network interfaces
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

What does this Dockerfile do?

  • FROM python:3.12-slim starts from a lightweight Python 3.12 image, so you do not need Python installed on your host machine.
  • COPY requirements.txt and RUN pip install install your dependencies in a separate layer. Docker caches this layer, so rebuilds are fast unless you change requirements.txt.
  • COPY ./app ./app brings in your FastAPI source code.
  • CMD tells Docker to run uvicorn serving your FastAPI app on port 8000 when the container starts.
  • Save backend/Dockerfile.
  • You will not see a visible change yet. The next chunk (docker-compose.yml) uses this Dockerfile to build the container.

✔️ Awesome, I've got everything!

Great. Make sure you have saved backend/Dockerfile before moving on.

ⓧ I'd like to double check the full code

FROM python:3.12-slim

WORKDIR /code

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY ./app ./app

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Write the Docker Compose configuration

Docker Compose lets you define multiple services (containers) in a single file and start them all together. Your compose file will define two services: a PostgreSQL database and your FastAPI backend.

  • In VS Code, open the empty docker-compose.yml file at the root of expense-tracker/.
  • Paste the following configuration into it:
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: expenses
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

  api:
    build: ./backend
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql://postgres:postgres@db:5432/expenses
    depends_on:
      db:
        condition: service_healthy

volumes:
  postgres-data:

What does this configuration do?

The db service runs PostgreSQL using the lightweight postgres:16-alpine image. It stores data in a named volume (postgres-data) so your expenses survive container restarts.

The healthcheck uses pg_isready to verify PostgreSQL is accepting connections before the API starts. The api service builds from your Dockerfile, sets DATABASE_URL to point at the db service, and uses depends_on with condition: service_healthy to wait for the database.

Why a health check instead of just depends_on?

Without a health check, Docker only waits for the database container to start. It does not wait for PostgreSQL to be ready to accept connections. Your API would crash on startup trying to connect to a database that is still initializing.

The health check polls PostgreSQL every 5 seconds until it responds, ensuring the API only starts when the database is truly ready.

  • Save docker-compose.yml.
  • You will not see a visible change yet. The next substep runs the compose stack.

✔️ Awesome, I've got everything!

Great. Make sure docker-compose.yml is saved before moving on.

ⓧ I'd like to double check the full code

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: expenses
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

  api:
    build: ./backend
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql://postgres:postgres@db:5432/expenses
    depends_on:
      db:
        condition: service_healthy

volumes:
  postgres-data:
Start the stack and test the API

Both configuration files are in place. Now you will build the Docker image and start both containers with a single command.

  • In VS Code, open a terminal (Ctrl+`).
  • Make sure you are in the expense-tracker/ directory (the folder containing docker-compose.yml).
  • Build and start both containers by running:
docker compose up --build

What does this command do?

  • docker compose up starts all the services defined in your docker-compose.yml.
  • --build forces Docker to rebuild the api image from your Dockerfile before starting. This ensures any code changes are included.

You will see logs from both services streaming in the terminal. Wait until you see a line like Uvicorn running on http://0.0.0.0:8000 in the output.

  • Open your browser and go to http://localhost:8000/docs.

You should see the Swagger UI with all four endpoints listed: POST /expenses, GET /expenses, GET /expenses/{expense_id}, and DELETE /expenses/{expense_id}.

  • Click on POST /expenses to expand it.
  • Click Try it out.
  • Replace the example JSON body with this:
{
  "title": "Coffee",
  "amount": 4.50,
  "category": "food"
}
  • Click Execute.
  • You should see a 200 response with the created expense, including an auto-generated id and created_at timestamp.
  • Now click on GET /expenses, click Try it out, then click Execute.
  • You should see a list containing the expense you just created.

Containers not starting?

  • Make sure Docker Desktop is running. Check for the Docker whale icon in your system tray.
  • If you see a port conflict error mentioning 5432 or 8000, another process is using that port. Stop the conflicting process or change the host port in docker-compose.yml.
  • If the api service keeps restarting with a connection error, the health check might need more retries. Increase retries: 5 to retries: 10 in the db health check.

Help me debug why my Docker containers are not starting for my FastAPI and PostgreSQL project.

Your containerized backend is live. Next up, you will build the Flutter app that connects to this API and lets you manage expenses from your phone.

Build the Flutter Expense Tracker UI

Your containerized backend is live and serving requests. Now it is time to build the app that talks to it.

In this step, you will build the Flutter frontend that communicates with your FastAPI backend over HTTP. You will create a data model that mirrors the API response, a service class that handles all network calls, and a full UI with a list view and add-expense dialog.

In this step, get ready to:
  • Set up the HTTP client and create a Dart data model.
  • Create an API service class that encapsulates backend communication.
  • Build the expense list screen and add-expense dialog.
Set up the HTTP client and model

Before your Flutter app can talk to the backend, it needs two things: an HTTP package to make network requests, and a Dart class that mirrors the JSON shape the API returns.

  • Switch to the terminal in VS Code.
  • Navigate into the Flutter project by running this command:
cd frontend
  • Add the http package by running:
flutter pub add http

What does this command do?

This adds the http package (version ^1.6.0) to your pubspec.yaml dependencies and downloads it. The http package provides simple methods for making GET, POST, and DELETE requests to REST APIs.

  • Confirm the package was added by checking the terminal output for a line mentioning http in your dependencies.

Seeing a resolve error?

Make sure you are inside the frontend/ directory before running the command. If you get a connectivity error, check that your machine has internet access.

Help me troubleshoot this.

Now you will create the Dart model class that represents an expense. This class knows how to convert between JSON (what the API sends) and a typed Dart object (what your UI code works with).

  • In the VS Code Explorer sidebar, expand frontend/lib/.
  • Right-click the lib folder and select New Folder. Name it models.
  • Right-click the new models folder and select New File. Name it expense.dart.
  • Paste the following code into expense.dart:
class Expense {
  final int id;
  final String title;
  final double amount;
  final String category;
  final DateTime createdAt;

  Expense({
    required this.id,
    required this.title,
    required this.amount,
    required this.category,
    required this.createdAt,
  });

  // Convert JSON from the API into a typed Expense object
  factory Expense.fromJson(Map<String, dynamic> json) {
    return Expense(
      id: json['id'] as int,
      title: json['title'] as String,
      amount: (json['amount'] as num).toDouble(),
      category: json['category'] as String,
      createdAt: DateTime.parse(json['created_at'] as String),
    );
  }

  // Convert an Expense into JSON for sending to the API
  Map<String, dynamic> toJson() {
    return {
      'title': title,
      'amount': amount,
      'category': category,
    };
  }
}

What does this code do?

The Expense class has five fields that match the API response: id, title, amount, category, and createdAt.

The fromJson factory constructor turns a raw JSON map into a typed Dart object. The toJson method does the reverse for POST requests. Notice toJson only sends title, amount, and category because the backend generates id and created_at automatically.

  • Save expense.dart.

Seeing red underlines in VS Code?

Make sure the file is saved at exactly frontend/lib/models/expense.dart. Dart's analyzer needs the correct file path to resolve imports.

Help me debug my expense.dart file.

✔️ Awesome, I've got everything!

Great. Make sure you have saved expense.dart before moving on.

ⓧ I'd like to double check the full code

class Expense {
  final int id;
  final String title;
  final double amount;
  final String category;
  final DateTime createdAt;

  Expense({
    required this.id,
    required this.title,
    required this.amount,
    required this.category,
    required this.createdAt,
  });

  factory Expense.fromJson(Map<String, dynamic> json) {
    return Expense(
      id: json['id'] as int,
      title: json['title'] as String,
      amount: (json['amount'] as num).toDouble(),
      category: json['category'] as String,
      createdAt: DateTime.parse(json['created_at'] as String),
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'title': title,
      'amount': amount,
      'category': category,
    };
  }
}
Create the API service

Your Flutter app needs a single place to manage all backend communication. An API service class keeps your HTTP logic organized and reusable across multiple screens.

  • In the VS Code Explorer sidebar, right-click the lib folder and select New Folder. Name it services.
  • Right-click the services folder and select New File. Name it api_service.dart.
  • Paste the following code into api_service.dart:
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/expense.dart';

class ApiService {
  // Android emulator alias for host machine's localhost
  static const String baseUrl = 'http://10.0.2.2:8000';

  // Fetch all expenses from the backend
  Future<List<Expense>> getExpenses() async {
    final response = await http.get(
      Uri.parse('$baseUrl/expenses'),
      headers: {'Accept': 'application/json'},
    );

    if (response.statusCode == 200) {
      final List<dynamic> jsonList =
          jsonDecode(response.body) as List<dynamic>;
      return jsonList
          .map((json) => Expense.fromJson(json as Map<String, dynamic>))
          .toList();
    } else {
      throw Exception('Failed to load expenses');
    }
  }

  // Send a new expense to the backend
  Future<Expense> createExpense({
    required String title,
    required double amount,
    required String category,
  }) async {
    final response = await http.post(
      Uri.parse('$baseUrl/expenses'),
      headers: {'Content-Type': 'application/json; charset=UTF-8'},
      body: jsonEncode({
        'title': title,
        'amount': amount,
        'category': category,
      }),
    );

    if (response.statusCode == 200) {
      return Expense.fromJson(
          jsonDecode(response.body) as Map<String, dynamic>);
    } else {
      throw Exception('Failed to create expense');
    }
  }

  // Delete an expense by its ID
  Future<void> deleteExpense(int id) async {
    final response = await http.delete(
      Uri.parse('$baseUrl/expenses/$id'),
      headers: {'Accept': 'application/json'},
    );

    if (response.statusCode != 200) {
      throw Exception('Failed to delete expense');
    }
  }
}

What does this code do?

The ApiService class wraps all HTTP calls behind clean method signatures. It uses http://10.0.2.2:8000 as the base URL because 10.0.2.2 is the Android emulator's alias for your host machine's localhost.

Each method follows the same pattern: make the request, check the status code, then either parse the response or throw an exception. The jsonDecode function converts the raw response body into Dart maps, and Expense.fromJson turns those maps into typed objects.

Why 10.0.2.2 instead of localhost?

Inside the Android emulator, localhost refers to the emulator itself, not your computer. The special IP 10.0.2.2 is a built-in alias that routes traffic from the emulator to your host machine's localhost. If you use a physical Android device instead, you will replace this with your PC's local IP address in the next step.

  • Save api_service.dart.
  • You should see no red underlines in the file. The import for ../models/expense.dart resolves because you created that file in the previous substep.

Seeing an unresolved import error?

Double-check that expense.dart is inside frontend/lib/models/ (not frontend/lib/ directly). The relative import path ../models/expense.dart expects that exact folder structure.

Help me fix my file paths.

✔️ Awesome, I've got everything!

Great. Make sure you have saved api_service.dart before moving on.

ⓧ I'd like to double check the full code

import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/expense.dart';

class ApiService {
  static const String baseUrl = 'http://10.0.2.2:8000';

  Future<List<Expense>> getExpenses() async {
    final response = await http.get(
      Uri.parse('$baseUrl/expenses'),
      headers: {'Accept': 'application/json'},
    );

    if (response.statusCode == 200) {
      final List<dynamic> jsonList =
          jsonDecode(response.body) as List<dynamic>;
      return jsonList
          .map((json) => Expense.fromJson(json as Map<String, dynamic>))
          .toList();
    } else {
      throw Exception('Failed to load expenses');
    }
  }

  Future<Expense> createExpense({
    required String title,
    required double amount,
    required String category,
  }) async {
    final response = await http.post(
      Uri.parse('$baseUrl/expenses'),
      headers: {'Content-Type': 'application/json; charset=UTF-8'},
      body: jsonEncode({
        'title': title,
        'amount': amount,
        'category': category,
      }),
    );

    if (response.statusCode == 200) {
      return Expense.fromJson(
          jsonDecode(response.body) as Map<String, dynamic>);
    } else {
      throw Exception('Failed to create expense');
    }
  }

  Future<void> deleteExpense(int id) async {
    final response = await http.delete(
      Uri.parse('$baseUrl/expenses/$id'),
      headers: {'Accept': 'application/json'},
    );

    if (response.statusCode != 200) {
      throw Exception('Failed to delete expense');
    }
  }
}
Build the expense list and add-expense screens

With the model and service ready, you can now build the actual UI. You will replace the default Flutter counter app with a full expense tracker screen that fetches data, displays a list, and lets users add or delete expenses.

  • Open frontend/lib/main.dart in VS Code.
  • Select all the existing code (Cmd+A on macOS or Ctrl+A on Windows) and delete it.
  • Paste the following app shell that sets up the MaterialApp and theme:
import 'package:flutter/material.dart';
import 'services/api_service.dart';
import 'models/expense.dart';

void main() {
  runApp(const ExpenseTrackerApp());
}

// Root widget that configures the app theme
class ExpenseTrackerApp extends StatelessWidget {
  const ExpenseTrackerApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Expense Tracker',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      home: const ExpenseListScreen(),
    );
  }
}

What does this code do?

This sets up the app entry point and the root MaterialApp widget. The ColorScheme.fromSeed(seedColor: Colors.teal) generates a full Material 3 color palette from a single teal seed color.

The home property points to ExpenseListScreen, which you will define next.

  • Directly below the ExpenseTrackerApp class, paste the main screen widget and its state class:
// The main screen that displays the list of expenses
class ExpenseListScreen extends StatefulWidget {
  const ExpenseListScreen({super.key});

  @override
  State<ExpenseListScreen> createState() => _ExpenseListScreenState();
}

class _ExpenseListScreenState extends State<ExpenseListScreen> {
  final ApiService _apiService = ApiService();
  late Future<List<Expense>> _expensesFuture;

  @override
  void initState() {
    super.initState();
    _expensesFuture = _apiService.getExpenses();
  }

  // Refresh the list by re-fetching from the API
  void _refreshExpenses() {
    setState(() {
      _expensesFuture = _apiService.getExpenses();
    });
  }

  // Show a dialog with fields for adding a new expense
  void _showAddExpenseDialog() {
    final titleController = TextEditingController();
    final amountController = TextEditingController();
    final categoryController = TextEditingController();

    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Add Expense'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            TextField(
              controller: titleController,
              decoration: const InputDecoration(labelText: 'Title'),
            ),
            TextField(
              controller: amountController,
              decoration: const InputDecoration(labelText: 'Amount'),
              keyboardType:
                  const TextInputType.numberWithOptions(decimal: true),
            ),
            TextField(
              controller: categoryController,
              decoration: const InputDecoration(labelText: 'Category'),
            ),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () async {
              final title = titleController.text.trim();
              final amount =
                  double.tryParse(amountController.text.trim()) ?? 0;
              final category = categoryController.text.trim();

              if (title.isNotEmpty && amount > 0 && category.isNotEmpty) {
                await _apiService.createExpense(
                  title: title,
                  amount: amount,
                  category: category,
                );
                if (context.mounted) Navigator.pop(context);
                _refreshExpenses();
              }
            },
            child: const Text('Add'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Expense Tracker'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      // FutureBuilder handles loading, error, and data states
      body: FutureBuilder<List<Expense>>(
        future: _expensesFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          } else if (snapshot.hasError) {
            return Center(child: Text('Error: ${snapshot.error}'));
          } else if (!snapshot.hasData || snapshot.data!.isEmpty) {
            return const Center(child: Text('No expenses yet. Add one!'));
          }

          final expenses = snapshot.data!;
          return ListView.builder(
            itemCount: expenses.length,
            itemBuilder: (context, index) {
              final expense = expenses[index];
              // Dismissible enables swipe-to-delete
              return Dismissible(
                key: Key(expense.id.toString()),
                direction: DismissDirection.endToStart,
                background: Container(
                  color: Colors.red,
                  alignment: Alignment.centerRight,
                  padding: const EdgeInsets.only(right: 16),
                  child:
                      const Icon(Icons.delete, color: Colors.white),
                ),
                onDismissed: (_) async {
                  await _apiService.deleteExpense(expense.id);
                  _refreshExpenses();
                },
                child: ListTile(
                  title: Text(expense.title),
                  subtitle: Text(expense.category),
                  trailing: Text(
                    '\$${expense.amount.toStringAsFixed(2)}',
                    style: const TextStyle(
                      fontWeight: FontWeight.bold,
                      fontSize: 16,
                    ),
                  ),
                ),
              );
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _showAddExpenseDialog,
        child: const Icon(Icons.add),
      ),
    );
  }
}

What does this code do?

The _ExpenseListScreenState class manages the screen's data and interactions:

  • A FutureBuilder widget automatically shows a loading spinner, an error message, or the expense list depending on the API response state.
  • Each expense is wrapped in a Dismissible widget, which lets the user swipe left to delete it. On dismiss, it calls the DELETE endpoint and refreshes the list.
  • The FloatingActionButton opens an AlertDialog with three text fields. When the user taps Add, it validates the inputs and calls createExpense on the API service.
  • The _refreshExpenses method triggers a setState that re-fetches the list from the backend, keeping the UI in sync.
  • Save main.dart.
  • You should see no red underlines. The imports for api_service.dart and expense.dart resolve because you created both files earlier in this step.

Seeing errors about missing imports?

Verify that your folder structure is lib/services/api_service.dart and lib/models/expense.dart. The imports in main.dart use relative paths from the lib/ directory.

Help me fix my folder structure.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.dart with both sections of code.

ⓧ I'd like to double check the full code

import 'package:flutter/material.dart';
import 'services/api_service.dart';
import 'models/expense.dart';

void main() {
  runApp(const ExpenseTrackerApp());
}

class ExpenseTrackerApp extends StatelessWidget {
  const ExpenseTrackerApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Expense Tracker',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      home: const ExpenseListScreen(),
    );
  }
}

class ExpenseListScreen extends StatefulWidget {
  const ExpenseListScreen({super.key});

  @override
  State<ExpenseListScreen> createState() => _ExpenseListScreenState();
}

class _ExpenseListScreenState extends State<ExpenseListScreen> {
  final ApiService _apiService = ApiService();
  late Future<List<Expense>> _expensesFuture;

  @override
  void initState() {
    super.initState();
    _expensesFuture = _apiService.getExpenses();
  }

  void _refreshExpenses() {
    setState(() {
      _expensesFuture = _apiService.getExpenses();
    });
  }

  void _showAddExpenseDialog() {
    final titleController = TextEditingController();
    final amountController = TextEditingController();
    final categoryController = TextEditingController();

    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Add Expense'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            TextField(
              controller: titleController,
              decoration: const InputDecoration(labelText: 'Title'),
            ),
            TextField(
              controller: amountController,
              decoration: const InputDecoration(labelText: 'Amount'),
              keyboardType:
                  const TextInputType.numberWithOptions(decimal: true),
            ),
            TextField(
              controller: categoryController,
              decoration: const InputDecoration(labelText: 'Category'),
            ),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () async {
              final title = titleController.text.trim();
              final amount =
                  double.tryParse(amountController.text.trim()) ?? 0;
              final category = categoryController.text.trim();

              if (title.isNotEmpty && amount > 0 && category.isNotEmpty) {
                await _apiService.createExpense(
                  title: title,
                  amount: amount,
                  category: category,
                );
                if (context.mounted) Navigator.pop(context);
                _refreshExpenses();
              }
            },
            child: const Text('Add'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Expense Tracker'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: FutureBuilder<List<Expense>>(
        future: _expensesFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          } else if (snapshot.hasError) {
            return Center(child: Text('Error: ${snapshot.error}'));
          } else if (!snapshot.hasData || snapshot.data!.isEmpty) {
            return const Center(child: Text('No expenses yet. Add one!'));
          }

          final expenses = snapshot.data!;
          return ListView.builder(
            itemCount: expenses.length,
            itemBuilder: (context, index) {
              final expense = expenses[index];
              return Dismissible(
                key: Key(expense.id.toString()),
                direction: DismissDirection.endToStart,
                background: Container(
                  color: Colors.red,
                  alignment: Alignment.centerRight,
                  padding: const EdgeInsets.only(right: 16),
                  child: const Icon(Icons.delete, color: Colors.white),
                ),
                onDismissed: (_) async {
                  await _apiService.deleteExpense(expense.id);
                  _refreshExpenses();
                },
                child: ListTile(
                  title: Text(expense.title),
                  subtitle: Text(expense.category),
                  trailing: Text(
                    '\$${expense.amount.toStringAsFixed(2)}',
                    style: const TextStyle(
                      fontWeight: FontWeight.bold,
                      fontSize: 16,
                    ),
                  ),
                ),
              );
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _showAddExpenseDialog,
        child: const Icon(Icons.add),
      ),
    );
  }
}

Now you need to allow your app to make unencrypted HTTP requests to your local backend. By default, Android blocks cleartext (non-HTTPS) traffic.

  • Open frontend/android/app/src/main/AndroidManifest.xml in VS Code.
  • Find the <application tag and add android:usesCleartextTraffic="true" as an attribute, so the opening tag looks like this:
<application
    android:usesCleartextTraffic="true"
    android:label="frontend"
    ...>

Why enable cleartext traffic?

Your backend runs on http:// (not https://) because it is a local development server. Android blocks unencrypted HTTP by default as a security measure. Adding usesCleartextTraffic allows your app to reach the local API during development.

  • Save AndroidManifest.xml.
  • Make sure your Docker containers are still running. In a separate terminal, verify by running:
docker compose ps
  • You should see both api and db services listed as running.
  • Connect your Android device via USB (or start an Android emulator).
  • Launch the Flutter app by running this command from the frontend/ directory:
flutter run

What should I see?

The app builds and deploys to your device. You should see an app bar titled Expense Tracker with a teal theme, a message saying "No expenses yet. Add one!" in the center, and a floating action button (+ icon) in the bottom right corner.

  • Tap the + button to open the add expense dialog.
  • Enter Coffee for title, 4.50 for amount, and food for category.
  • Tap Add. The expense should appear in the list with the title, category, and dollar amount.

App shows an error instead of the expense list?

If you see a network error, confirm that Docker Compose is running in a separate terminal (docker compose ps should show both services). If using a physical device, you will need to replace 10.0.2.2 with your PC's local IP in api_service.dart (covered in the next step).

Help me troubleshoot my network connection.

Your Flutter frontend is fully wired up. Next up, you will test the full stack end-to-end and verify that data persists across container restarts.

Connect and Test the Full Stack

Your Flutter app is built and your backend containers are running. Now it is time to connect the two layers and prove the full stack works as a single system.

In this step, you will launch the Flutter app against the live API, add expenses through the mobile UI, and verify that your data survives container restarts thanks to Docker volumes.

In this step, get ready to:
  • Run the Flutter app against the live Docker backend.
  • Verify expense data persists across container restarts.
  • Understand how Docker Compose maps to cloud platforms.
Run the full system end-to-end

Your Docker Compose stack is already running from the previous step. The API is live at http://localhost:8000 and ready to accept requests from your Flutter app.

  • In VS Code, open the integrated terminal.
  • Navigate to the frontend/ directory and launch the app on your connected Android device by running:
cd frontend
flutter run

Emulator vs physical device

If you are using the Android emulator, the app connects to http://10.0.2.2:8000 automatically. The emulator uses 10.0.2.2 as an alias for your host machine's localhost.

If you are using a physical Android device on the same Wi-Fi network, you need to replace 10.0.2.2 with your PC's local IP address. Find it with ipconfig (Windows) or ipconfig getifaddr en0 (macOS) in a terminal. Update the baseUrl in api_service.dart to use that IP instead.

  • Wait for the app to launch on your device. You should see the Expense Tracker screen with the message "No expenses yet. Add one!" in the center.
  • Tap the floating + button in the bottom-right corner.
  • Enter Coffee in the Title field.
  • Enter 4.50 in the Amount field.
  • Enter food in the Category field.
  • Tap Add.

The expense appears in the list immediately with the title, category, and amount displayed.

  • Add two more expenses to confirm the flow works repeatedly. For example, Bus pass / 50.00 / transport and Notebook / 12.99 / supplies.
  • Test deleting an expense by swiping any list item from right to left. The item disappears and is removed from the database.

Seeing a connection error?

  • Make sure your Docker containers are still running. Switch to the terminal where you ran docker compose up and confirm there are no error messages.
  • If using a physical device, double-check that your phone and PC are on the same Wi-Fi network. The baseUrl must point to your PC's local IP, not 10.0.2.2.
  • Verify the API is reachable by opening http://localhost:8000/docs in your browser. If Swagger UI loads, the API is healthy.

Why can't my Flutter app connect to the Docker backend?

Verify data persistence

Docker volumes store data on your machine's disk, independent of container lifecycle. Let's prove that your expenses survive a full container teardown and rebuild.

  • Open a new terminal in VS Code (keep the flutter run terminal running).
  • Navigate to the expense-tracker/ root directory (where docker-compose.yml lives).
  • Stop and remove the containers by running:
docker compose down

What does this command do?

The docker compose down command stops and removes all containers defined in your docker-compose.yml. However, it does NOT remove named volumes. Your postgres-data volume and all the expense data inside it remain on disk.

  • Start the stack again by running:
docker compose up
  • Wait for the log output to show both the db and api services are healthy and ready.
  • Switch back to the terminal running your Flutter app and press r to trigger a hot restart.

All your previously added expenses should appear in the list. The data survived a complete container teardown because PostgreSQL stored it in the named volume.

Why named volumes matter

Without a named volume, all data inside a container is lost when the container is removed. The postgres-data volume in your docker-compose.yml maps the database's internal storage to a persistent location on your host machine.

This is the same pattern used in production. Cloud-managed databases (like AWS RDS or Google Cloud SQL) handle persistence under the hood, but the principle is identical.

Expenses disappeared after restart?

  • If you accidentally ran docker compose down -v (with the -v flag), that removes named volumes along with containers. The -v flag permanently deletes your data. Run docker compose up again and re-add your expenses.
  • Make sure you waited for the health check to pass before hot-restarting the Flutter app. The API container must finish starting before it can serve requests.

Why did my expense data disappear after restarting Docker?

Understand the cloud deployment path

Your Docker Compose setup is not just a local development tool. It is a deployment blueprint that maps directly to cloud platforms.

How does this map to cloud?

Here is how your two-service architecture translates to real cloud infrastructure:

  • Railway / Render: Push the backend/ folder to a Git repo, connect to Railway or Render, and it builds from the Dockerfile automatically. Both offer managed PostgreSQL databases.
  • AWS ECS / Google Cloud Run: Push your Docker image to a container registry, then deploy it as a service with a managed PostgreSQL instance (RDS or Cloud SQL).

The docker-compose.yml you built is the blueprint. Cloud platforms run the same containers on their infrastructure. The only changes are the database connection string and the host/port configuration.

The key insight is that containerizing your backend means you can deploy it anywhere that runs Docker. Your local development environment and your production environment run the exact same code in the exact same way.

Secret mission

Add Category Filtering and a Spending Summary

Your expense tracker works, but it treats every expense the same. What if you could filter by category and see exactly where your money is going? In this secret mission, you'll add query parameter filtering and a spending aggregation endpoint that touches every layer of the stack.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with Docker, so there are no ongoing cloud costs.

Resources you used:

  • Docker containers (api and db) running locally.
  • Docker volume (postgres-data) storing your expense data on disk.
  • Local project folder (expense-tracker/) on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your expense tracker.

  • Leave Docker Compose running. The containers use minimal resources when idle.
  • Your expense data stays in the postgres-data volume, ready for the next time you open the app.
  • Docker Desktop continues running in the background.

Pause - I'll come back to this later

Shut down running containers to free up memory, but keep your data and project files for later.

  • Stop the running containers by running this command in your terminal from the expense-tracker/ folder:
docker compose stop
  • This pauses the containers without deleting your expense data. To resume later, run docker compose up from the same folder.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your expense data.

  • Remove the Docker containers and the database volume by running this command from the expense-tracker/ folder:
docker compose down -v
  • Delete the local project folder. On macOS or Linux:
cd ~/Desktop
rm -rf expense-tracker
  • On Windows (PowerShell):
cd ~\Desktop
Remove-Item -Recurse -Force expense-tracker
  • Docker Desktop remains installed on your machine. You can uninstall it through Settings > Apps > Apps & features on Windows if you no longer need it.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-style full-stack mobile application with a Flutter frontend, a FastAPI backend, and a PostgreSQL database, all orchestrated with Docker Compose.

You've proven you can build, containerize, and connect all layers of a modern app architecture.

You've learned how to:

  • Build a FastAPI REST API with full CRUD operations and auto-generated Swagger UI documentation.
  • Containerize a Python backend and PostgreSQL database with Docker Compose, including health checks and named volumes for data persistence.
  • Connect a Flutter mobile app to a containerized backend via HTTP, handling JSON serialization, error states, and swipe-to-delete interactions.
  • Secret Mission: Added category filtering with query parameters and a spending summary endpoint using SQL aggregation, then wired both into the Flutter UI.

Ready to quiz yourself?