Build a Canteen Management App
Build a Flutter mobile app with Supabase to manage inventory and orders.
Introduction
30 Second Summary
School canteens often track inventory and sales with pen and paper or messy spreadsheets. When items run low or profits need calculating, staff scramble to piece together scattered records.
In this project, you will build a mobile canteen management app using Flutter and Supabase that handles product inventory, order processing, and a dashboard with real-time business metrics.
What You'll Build
A Flutter Android app backed by a PostgreSQL database on Supabase that lets staff authenticate, manage products with buy and sell prices, process customer orders that auto-deduct inventory, and view aggregate stats on a dashboard.
By the end of this project, you'll have:
- A working login screen with email/password authentication powered by Supabase Auth.
- A product management system with full CRUD operations to add, edit, and delete products with buy prices, sell prices, and quantities.
- An order processing screen that computes totals, calculates profit, and automatically deducts stock from inventory.
- A dashboard displaying total sales, total profit, product count, low-stock alerts, and transaction count.
- Secret Mission: Add a daily and monthly sales and profit report screen with date filtering.
Are there any prerequisites?
You should have basic programming knowledge in Python or JavaScript. A conceptual understanding of how databases store and relate data is helpful but not required.
You will also need Flutter SDK, Android Studio, VS Code with the Flutter extension, Git, and a free Supabase account. Step 1 walks through verifying your setup.
Create the Flutter Project and Add Dependencies
Before you can build any screens or connect to a database, you need a working Flutter project with the right packages installed.
In this step, you will verify your Flutter environment is ready, scaffold a new project, and pull in three key packages: one for backend connectivity, one for state management, and one for formatting.
In this step, get ready to:
- Verify your Flutter installation is healthy for Android development.
- Create a new Flutter project and open it in VS Code.
- Add dependencies and configure internet permissions.
Verify your Flutter installation
Flutter includes a built-in diagnostic command that checks whether your system has everything needed for Android development.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Run the Flutter diagnostic check by running this command:
flutter doctor
✔️ All checks pass for Android development
Your environment is ready. Move on to creating the project below.
ⓧ I see issues or missing components
The output tells you exactly what is missing. Focus on the Android toolchain section.
- If Android SDK is missing, open Android Studio and go to Settings > SDK Manager to install the required SDK.
- If Android licenses are not accepted, run flutter doctor --android-licenses and accept each license when prompted.
- Run flutter doctor again after fixing issues to confirm they are resolved.
Create the Flutter project
Flutter's CLI can scaffold a complete project structure for you in one command. This gives you all the folders, configuration files, and boilerplate needed to run a mobile app.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the new Flutter project by running this command:
flutter create ctu_canteen
What does this command do?
The flutter create command generates a complete project scaffold inside a folder named ctu_canteen. It includes an android/ folder for native Android configuration, a lib/ folder where your Dart code lives, and a pubspec.yaml file that declares your project's dependencies.
- Move into the newly created project folder by running:
cd ctu_canteen
- Confirm the project was created by running:
ls
You should see folders like android, lib, and test, plus files like pubspec.yaml in the output.
- Open the project in VS Code by running this command:
code .
Command not found when running code?
If code . does not work, open VS Code manually. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Shell Command: Install, and select it. Then try code . again.
Alternatively, open VS Code and use File > Open Folder to navigate to the ctu_canteen folder on your Desktop.
Still stuck? Help me open my Flutter project in VS Code
Add project dependencies
Your canteen app needs three external packages to work. Supabase provides the backend database and authentication. Provider manages shared state across screens. The intl package formats numbers and dates consistently.
- In the VS Code Explorer sidebar on the left, click on pubspec.yaml to open it.
- Find the dependencies: section (usually around line 9).
- Replace the entire dependencies: block with the following:
dependencies:
flutter:
sdk: flutter
supabase_flutter: ^2.12.4
provider: ^6.1.5
intl: ^0.19.0
What do these packages do?
- supabase_flutter connects your Flutter app to a Supabase backend for database queries and user authentication.
- provider lets you share and update data (like a product list or a user session) across multiple screens without passing it manually.
- intl provides number and date formatting so currency values display consistently (e.g. ₱1,250.00 instead of 1250.0).
- Save pubspec.yaml by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, top left of your keyboard).
- Install the dependencies by running this command:
flutter pub get
You should see output ending with a message like Got dependencies! confirming the packages were downloaded successfully.
Seeing dependency resolution errors?
Check that your indentation in pubspec.yaml uses exactly 2 spaces per level. YAML is whitespace-sensitive, so a single extra space or a tab character will break the file.
Make sure you replaced the existing dependencies: block rather than adding a second one. There should be only one dependencies: key in the file.
Need more help? Help me fix my pubspec.yaml dependency errors
Now you need to add internet permission so the app can communicate with Supabase's servers when running on an Android device.
- In the VS Code Explorer sidebar, expand android > app > src > main.
- Click on AndroidManifest.xml to open it.
- Find the opening <manifest> tag at the top of the file, then add the internet permission line directly below it so your file starts like this:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required to fetch data from the internet. -->
<uses-permission android:name="android.permission.INTERNET" />
Why do I need an internet permission?
Android apps must explicitly declare that they need network access. Without this line, your app will silently fail when trying to reach Supabase's API. The debug build includes this permission automatically, but adding it explicitly ensures your app works in release builds too.
- Save AndroidManifest.xml.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is what your pubspec.yaml dependencies section should look like:
dependencies:
flutter:
sdk: flutter
supabase_flutter: ^2.12.4
provider: ^6.1.5
intl: ^0.19.0
Here is what the top of your AndroidManifest.xml should look like:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required to fetch data from the internet. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- ... rest of manifest ... -->
Your project skeleton is ready with all the packages it needs. Next up, you will set up the Supabase database that stores your products, orders, and sales data.
Set Up the Supabase Database
Your Flutter project is ready to connect to a backend. Now you need a database to store products, orders, and order items.
Supabase gives you a full PostgreSQL database with a REST API out of the box. In this step, you will create the database tables that power your canteen system.
In this step, get ready to:
- Create a Supabase project and retrieve your connection credentials.
- Build your database schema with products, orders, and order items tables.
- Secure your tables with Row Level Security policies.
Create a Supabase project and get your credentials
Supabase hosts your PostgreSQL database and provides an API that your Flutter app will call. You need a project URL and a publishable key to connect.
- Open your browser and navigate to database.new.
- Sign in with your Supabase account (or create one for free if you do not have one yet).
- Click New Project.
- Enter ctu-canteen as the project name.
- Set a database password and save it somewhere safe (you will not need it in the app, but Supabase requires one).
- Choose the region closest to you.
- Click Create new project.
Wait about 30 seconds for the project to finish provisioning. Once the dashboard loads, retrieve your connection details.
- Click the Connect button in the top right corner of your Supabase dashboard.
- Copy your Project URL and paste it somewhere you can access later (a notes app or text file).
- Copy the publishable key (also called anon/public key) and save it alongside the URL.
What are these credentials?
The Project URL is the address of your Supabase backend. Your Flutter app sends requests to this URL.
The publishable key identifies your app to Supabase. It is safe to include in client-side code because Row Level Security (which you will set up shortly) controls what data users can actually access.
Can't find the Connect button?
If your project is still provisioning, the Connect button will not appear yet. Wait until the dashboard fully loads and shows the project home page.
help me find my Supabase project URL and publishable key
Create the database tables
Your canteen system needs three tables. The products table stores inventory items with buy and sell prices. The orders table records each transaction. The order_items table links individual products to their parent order.
- In the left sidebar of your Supabase dashboard, click SQL Editor.
- Click New query to open a blank editor.
- Paste the following SQL script into the editor:
-- Create products table to store canteen inventory
CREATE TABLE products (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0,
buy_price DECIMAL(10,2) NOT NULL,
sell_price DECIMAL(10,2) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create orders table to record each transaction
CREATE TABLE orders (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
total_amount DECIMAL(10,2) NOT NULL,
total_profit DECIMAL(10,2) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create order_items table to link products to orders
CREATE TABLE order_items (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
order_id BIGINT REFERENCES orders(id) ON DELETE CASCADE,
product_id BIGINT REFERENCES products(id),
quantity INTEGER NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
subtotal DECIMAL(10,2) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
What does this SQL do?
- products stores each item's name, current stock quantity, buying price, and selling price. The difference between buy and sell price is your profit per unit.
- orders stores the total amount and total profit for each completed transaction.
- order_items connects an order to the specific products purchased, with quantity and subtotal per item. The REFERENCES keyword creates foreign key relationships between the tables.
- Click Run (or press Cmd+Enter on macOS, Ctrl+Enter on Windows).
- You should see a Success. No rows returned message at the bottom of the editor for each statement.
- Click Table Editor in the left sidebar to verify your tables were created.
- You should see products, orders, and order_items listed in the sidebar.
Seeing an error when running the SQL?
If you see a "relation already exists" error, you may have run the script twice. Click Table Editor in the sidebar to check whether the tables already exist.
If you see a syntax error, make sure you copied the entire SQL script without cutting off any lines.
help me fix my SQL error in Supabase
Enable Row Level Security and create access policies
Row Level Security (RLS) controls which users can read and write data in your tables. Without it, your tables are completely locked down by default in Supabase.
- Go back to SQL Editor in the left sidebar.
- Click New query to open a fresh editor.
- Paste the following SQL to enable RLS and create access policies:
-- Enable Row Level Security on all tables
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE order_items ENABLE ROW LEVEL SECURITY;
-- Create policies that allow authenticated users full access
CREATE POLICY "Allow all for authenticated users" ON products
FOR ALL TO authenticated USING (true) WITH CHECK (true);
CREATE POLICY "Allow all for authenticated users" ON orders
FOR ALL TO authenticated USING (true) WITH CHECK (true);
CREATE POLICY "Allow all for authenticated users" ON order_items
FOR ALL TO authenticated USING (true) WITH CHECK (true);
What does this SQL do?
ALTER TABLE ... ENABLE ROW LEVEL SECURITY activates RLS on each table. Once RLS is on, no one can access the table unless a policy explicitly grants permission.
The CREATE POLICY statements grant full read and write access to any user who is signed in (authenticated). Anonymous visitors cannot see or modify your data.
- Click Run to execute the script.
- You should see Success. No rows returned for each statement.
- Click Table Editor in the left sidebar.
- Select the products table.
- You should see a yellow shield icon or an "RLS enabled" badge confirming Row Level Security is active.
Tables show "RLS disabled" warning?
If the Table Editor still shows RLS as disabled, go back to the SQL Editor and re-run the ALTER TABLE statements. Make sure you ran the second script (not the table creation script again).
help me enable Row Level Security on my Supabase tables
Your database is ready. You have three connected tables secured with Row Level Security. Next up, you will wire your Flutter app to this backend and build the authentication screen.
Build Authentication
Your database is ready with tables for products, orders, and order items. Now you need a way to protect that data so only authorized staff can access the canteen system.
In this step, you will connect your Flutter app to Supabase, build an authentication provider that manages login state, and create a login screen with email and password fields.
In this step, get ready to:
- Set up the app entry point with Supabase initialization and Provider state management.
- Create an AuthProvider class that handles sign-in, sign-up, and sign-out.
- Build a login screen UI with form validation and error handling.
Set up the app entry point
Every Flutter app starts from a single main() function. Your entry point needs to initialize the Supabase connection before anything else loads, then wrap the entire app in a MultiProvider so every screen can access shared state.
First, create the folder structure your app needs. These folders organize your code into models, providers (state management), and screens (UI).
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, top-left of keyboard).
- Make sure you are inside your ctu_canteen project folder.
- Create the folder structure by running these commands:
mkdir -p lib/models lib/providers lib/screens
Now replace the default contents of lib/main.dart with your app entry point.
- Open lib/main.dart in the Explorer sidebar of VS Code.
- Select all existing code and delete it.
- Paste in the following code, replacing your-supabase-url-here with your Supabase Project URL and your-publishable-key-here with your publishable key from the Connect dialog:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'providers/auth_provider.dart';
import 'providers/product_provider.dart';
import 'providers/order_provider.dart';
import 'providers/dashboard_provider.dart';
import 'screens/login_screen.dart';
import 'screens/dashboard_screen.dart';
void main() async {
// Ensure Flutter bindings are ready before async operations
WidgetsFlutterBinding.ensureInitialized();
// Connect to your Supabase project
await Supabase.initialize(
url: 'your-supabase-url-here',
publishableKey: 'your-publishable-key-here',
);
runApp(const MyApp());
}
// Global reference to the Supabase client for use throughout the app
final supabase = Supabase.instance.client;
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
// Register all state providers so any screen can access them
providers: [
ChangeNotifierProvider(create: (_) => AuthProvider()),
ChangeNotifierProvider(create: (_) => ProductProvider()),
ChangeNotifierProvider(create: (_) => OrderProvider()),
ChangeNotifierProvider(create: (_) => DashboardProvider()),
],
child: MaterialApp(
title: 'CTU Canteen Management',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
useMaterial3: true,
),
// Route to dashboard if already logged in, otherwise show login
home: supabase.auth.currentSession != null
? const DashboardScreen()
: const LoginScreen(),
),
);
}
}
What does this code do?
WidgetsFlutterBinding.ensureInitialized() prepares Flutter for async work before the app starts. Supabase.initialize() connects your app to your Supabase project using the URL and publishable key you retrieved earlier.
MultiProvider makes all your state providers (auth, products, orders, dashboard) available to every widget in the tree. The home property checks if there is an existing login session. If yes, the user goes straight to the dashboard. If not, they see the login screen.
What is Provider?
Provider is a state management package for Flutter. It lets you create classes that hold your app's data (like the logged-in user or the product list) and automatically rebuilds any widget that depends on that data when it changes.
The main.dart file imports providers and screens that do not exist yet. You will build them in this step and the next steps. For now, create placeholder files so the app can compile.
- Create the file lib/providers/product_provider.dart with this placeholder code:
import 'package:flutter/material.dart';
// Placeholder - full implementation comes in Step 4
class ProductProvider extends ChangeNotifier {}
- Create the file lib/providers/order_provider.dart with this placeholder code:
import 'package:flutter/material.dart';
// Placeholder - full implementation comes in Step 5
class OrderProvider extends ChangeNotifier {}
- Create the file lib/providers/dashboard_provider.dart with this placeholder code:
import 'package:flutter/material.dart';
// Placeholder - full implementation comes in Step 6
class DashboardProvider extends ChangeNotifier {}
- Create the file lib/screens/dashboard_screen.dart with this placeholder code:
import 'package:flutter/material.dart';
// Placeholder - full implementation comes in Step 6
class DashboardScreen extends StatelessWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: Text('Dashboard - coming soon')),
);
}
}
- Save all files.
You will not see a visible change yet. The next substep adds the authentication logic that main.dart depends on.
Create the authentication provider
The AuthProvider class manages all login state for your app. It talks to Supabase Auth to sign users in, create new accounts, and sign users out. When any of these operations complete, the provider notifies the UI to rebuild.
Before writing the provider, you need to configure Supabase so new accounts can sign in immediately without email confirmation. This is important for your prototype.
- In your Supabase dashboard, click Authentication in the left sidebar.
- Click Settings at the top of the Authentication page.
- Scroll down to the Email section.
- Toggle off Confirm email.
- Click Save.
Why disable email confirmation?
By default, Supabase requires new users to click a verification link in their email before they can sign in. For a prototype that runs locally, this adds unnecessary friction. Disabling it lets you test sign-up and sign-in immediately.
Now create the provider that handles authentication logic.
- Create a new file at lib/providers/auth_provider.dart.
- Paste in the following code:
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class AuthProvider extends ChangeNotifier {
final _supabase = Supabase.instance.client;
bool _isLoading = false;
String? _errorMessage;
// Public getters for the UI to read state
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
User? get currentUser => _supabase.auth.currentUser;
bool get isAuthenticated => _supabase.auth.currentSession != null;
// Sign in with email and password
Future<bool> signIn(String email, String password) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
await _supabase.auth.signInWithPassword(
email: email,
password: password,
);
_isLoading = false;
notifyListeners();
return true;
} on AuthException catch (e) {
_errorMessage = e.message;
_isLoading = false;
notifyListeners();
return false;
}
}
// Create a new account
Future<bool> signUp(String email, String password) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
await _supabase.auth.signUp(
email: email,
password: password,
);
_isLoading = false;
notifyListeners();
return true;
} on AuthException catch (e) {
_errorMessage = e.message;
_isLoading = false;
notifyListeners();
return false;
}
}
// End the current session
Future<void> signOut() async {
await _supabase.auth.signOut();
notifyListeners();
}
}
What does this code do?
- _isLoading tracks whether an auth operation is in progress. The login screen uses this to show a loading spinner and disable the button.
- _errorMessage stores any error from Supabase (like "Invalid login credentials") so the UI can display it to the user.
- notifyListeners() tells any widget watching this provider to rebuild itself with the new state.
- Each method returns a bool so the login screen knows whether to navigate away or stay put.
- Save lib/providers/auth_provider.dart.
You will not see a visible change yet. The login screen in the next substep uses this provider to authenticate users.
Build the login screen
The login screen is the first thing canteen staff will see. It needs email and password fields with validation, a sign-in button, and a way to create a new account.
- Create a new file at lib/screens/login_screen.dart.
- Paste in the following code:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import 'dashboard_screen.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
// Attempt sign-in and navigate to dashboard on success
Future<void> _signIn() async {
if (!_formKey.currentState!.validate()) return;
final authProvider = context.read<AuthProvider>();
final success = await authProvider.signIn(
_emailController.text.trim(),
_passwordController.text.trim(),
);
if (success && mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const DashboardScreen()),
);
}
}
// Create a new account and show confirmation
Future<void> _signUp() async {
if (!_formKey.currentState!.validate()) return;
final authProvider = context.read<AuthProvider>();
final success = await authProvider.signUp(
_emailController.text.trim(),
_passwordController.text.trim(),
);
if (success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Account created! You can now sign in.')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// App logo and title
const Icon(
Icons.restaurant,
size: 80,
color: Colors.green,
),
const SizedBox(height: 16),
const Text(
'CTU Canteen Management',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'CTU Tabogon Extension',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 32),
// Email input field
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
return null;
},
),
const SizedBox(height: 16),
// Password input field
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
const SizedBox(height: 8),
// Error message display
Consumer<AuthProvider>(
builder: (context, auth, _) {
if (auth.errorMessage != null) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
auth.errorMessage!,
style: const TextStyle(color: Colors.red),
),
);
}
return const SizedBox.shrink();
},
),
const SizedBox(height: 16),
// Sign in button with loading state
Consumer<AuthProvider>(
builder: (context, auth, _) {
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: auth.isLoading ? null : _signIn,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: auth.isLoading
? const CircularProgressIndicator(
color: Colors.white)
: const Text('Sign In'),
),
);
},
),
const SizedBox(height: 12),
// Sign up link
TextButton(
onPressed: _signUp,
child: const Text('Create an account'),
),
],
),
),
),
),
),
);
}
}
What does this code do?
- GlobalKey<FormState> connects the form validation to the email and password fields. When validate() is called, each field runs its validator function.
- Consumer<AuthProvider> rebuilds only the widgets inside it when the auth state changes. This avoids rebuilding the entire screen when loading state toggles.
- Navigator.pushReplacement navigates to the dashboard and removes the login screen from the navigation stack, so pressing back does not return to login.
- Save lib/screens/login_screen.dart.
- In your terminal, run the app on your Android emulator by running:
flutter run
You should see the login screen with a green restaurant icon at the top, the title "CTU Canteen Management", email and password fields, a green Sign In button, and a "Create an account" link below it.
- Test sign-up by entering a test email (like test@example.com) and a password of at least 6 characters.
- Tap Create an account.
- You should see a green snackbar at the bottom saying "Account created! You can now sign in."
- Now tap Sign In with the same credentials.
- You should be navigated to the placeholder dashboard screen showing "Dashboard - coming soon".
Login not working?
- If you see "Invalid login credentials", make sure you tapped Create an account first to register the user before signing in.
- If the sign-up succeeds but sign-in fails, confirm you disabled Confirm email in your Supabase Authentication settings.
- If you see a network error, double-check that the URL and publishable key in main.dart match what your Supabase Connect dialog shows exactly.
Help me debug my Supabase authentication in Flutter
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file created in this step.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'providers/auth_provider.dart';
import 'providers/product_provider.dart';
import 'providers/order_provider.dart';
import 'providers/dashboard_provider.dart';
import 'screens/login_screen.dart';
import 'screens/dashboard_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Supabase.initialize(
url: 'your-supabase-url-here',
publishableKey: 'your-publishable-key-here',
);
runApp(const MyApp());
}
final supabase = Supabase.instance.client;
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthProvider()),
ChangeNotifierProvider(create: (_) => ProductProvider()),
ChangeNotifierProvider(create: (_) => OrderProvider()),
ChangeNotifierProvider(create: (_) => DashboardProvider()),
],
child: MaterialApp(
title: 'CTU Canteen Management',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
useMaterial3: true,
),
home: supabase.auth.currentSession != null
? const DashboardScreen()
: const LoginScreen(),
),
);
}
}
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class AuthProvider extends ChangeNotifier {
final _supabase = Supabase.instance.client;
bool _isLoading = false;
String? _errorMessage;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
User? get currentUser => _supabase.auth.currentUser;
bool get isAuthenticated => _supabase.auth.currentSession != null;
Future<bool> signIn(String email, String password) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
await _supabase.auth.signInWithPassword(
email: email,
password: password,
);
_isLoading = false;
notifyListeners();
return true;
} on AuthException catch (e) {
_errorMessage = e.message;
_isLoading = false;
notifyListeners();
return false;
}
}
Future<bool> signUp(String email, String password) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
await _supabase.auth.signUp(
email: email,
password: password,
);
_isLoading = false;
notifyListeners();
return true;
} on AuthException catch (e) {
_errorMessage = e.message;
_isLoading = false;
notifyListeners();
return false;
}
}
Future<void> signOut() async {
await _supabase.auth.signOut();
notifyListeners();
}
}
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import 'dashboard_screen.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _signIn() async {
if (!_formKey.currentState!.validate()) return;
final authProvider = context.read<AuthProvider>();
final success = await authProvider.signIn(
_emailController.text.trim(),
_passwordController.text.trim(),
);
if (success && mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const DashboardScreen()),
);
}
}
Future<void> _signUp() async {
if (!_formKey.currentState!.validate()) return;
final authProvider = context.read<AuthProvider>();
final success = await authProvider.signUp(
_emailController.text.trim(),
_passwordController.text.trim(),
);
if (success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Account created! You can now sign in.')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.restaurant,
size: 80,
color: Colors.green,
),
const SizedBox(height: 16),
const Text(
'CTU Canteen Management',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'CTU Tabogon Extension',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
const SizedBox(height: 8),
Consumer<AuthProvider>(
builder: (context, auth, _) {
if (auth.errorMessage != null) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
auth.errorMessage!,
style: const TextStyle(color: Colors.red),
),
);
}
return const SizedBox.shrink();
},
),
const SizedBox(height: 16),
Consumer<AuthProvider>(
builder: (context, auth, _) {
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: auth.isLoading ? null : _signIn,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: auth.isLoading
? const CircularProgressIndicator(
color: Colors.white)
: const Text('Sign In'),
),
);
},
),
const SizedBox(height: 12),
TextButton(
onPressed: _signUp,
child: const Text('Create an account'),
),
],
),
),
),
),
),
);
}
}
Your app now has a working authentication flow. Users can create accounts and sign in with email and password. Next up, you will build the product management system so staff can add, edit, and delete canteen items.
Build Product Management
Your authentication system is working. Staff can sign up, log in, and reach the dashboard. Now it's time to build the core feature of the canteen app: product management.
A canteen needs to track what it sells, how much stock is available, and the buy/sell prices for profit calculations. In this step, you'll create a data model, a Provider that handles all database operations, and a screen where staff can view, add, edit, and delete products.
In this step, get ready to:
- Create a Product model class that maps to your Supabase database columns.
- Build a ProductProvider with full CRUD methods (fetch, add, update, delete).
- Build a products screen with a scrollable list, add dialog, edit dialog, and delete confirmation.
Create the Product model
Before you can display or manipulate product data, you need a Dart class that represents a product. This model maps between the JSON your Supabase database returns and the typed objects your app uses.
- Create a new folder called models inside your lib directory.
- Inside lib/models/, create a new file called product.dart and add the following code:
class Product {
final int? id;
final String name;
final int quantity;
final double buyPrice;
final double sellPrice;
final DateTime? createdAt;
Product({
this.id,
required this.name,
required this.quantity,
required this.buyPrice,
required this.sellPrice,
this.createdAt,
});
// Convert JSON from Supabase into a Product object
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'] as int?,
name: json['name'] as String,
quantity: json['quantity'] as int,
buyPrice: (json['buy_price'] as num).toDouble(),
sellPrice: (json['sell_price'] as num).toDouble(),
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: null,
);
}
// Convert a Product object into JSON for Supabase
Map<String, dynamic> toJson() {
return {
'name': name,
'quantity': quantity,
'buy_price': buyPrice,
'sell_price': sellPrice,
};
}
}
What does this code do?
The Product class has fields matching your Supabase products table columns: id, name, quantity, buy_price, and sell_price.
- Product.fromJson() converts the raw JSON map from Supabase into a typed Dart object your app can work with.
- toJson() converts a Product back into a map for inserting or updating rows in the database.
- id and createdAt are nullable because the database generates them automatically.
- Save lib/models/product.dart.
You won't see a change yet. The next chunk uses this model to talk to your database.
Create the ProductProvider
The ProductProvider is the bridge between your UI and the database. It stores the list of products in memory and exposes methods to fetch, add, update, and delete them from Supabase.
- Create a new file called product_provider.dart inside lib/providers/ and add the following code:
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/product.dart';
class ProductProvider extends ChangeNotifier {
final _supabase = Supabase.instance.client;
List<Product> _products = [];
bool _isLoading = false;
List<Product> get products => _products;
bool get isLoading => _isLoading;
// Fetch all products from the database, newest first
Future<void> fetchProducts() async {
_isLoading = true;
notifyListeners();
try {
final data = await _supabase
.from('products')
.select()
.order('created_at', ascending: false);
_products = (data as List)
.map((item) => Product.fromJson(item))
.toList();
} catch (e) {
debugPrint('Error fetching products: $e');
}
_isLoading = false;
notifyListeners();
}
// Insert a new product into the database
Future<void> addProduct(Product product) async {
try {
await _supabase.from('products').insert(product.toJson());
await fetchProducts();
} catch (e) {
debugPrint('Error adding product: $e');
}
}
// Update an existing product by ID
Future<void> updateProduct(int id, Product product) async {
try {
await _supabase
.from('products')
.update(product.toJson())
.eq('id', id);
await fetchProducts();
} catch (e) {
debugPrint('Error updating product: $e');
}
}
// Delete a product by ID
Future<void> deleteProduct(int id) async {
try {
await _supabase.from('products').delete().eq('id', id);
await fetchProducts();
} catch (e) {
debugPrint('Error deleting product: $e');
}
}
}
What does this code do?
- fetchProducts() queries the products table and converts each row into a Product object. It orders results by created_at so the newest products appear first.
- addProduct() inserts a new row, then re-fetches the full list so the UI stays in sync.
- updateProduct() and deleteProduct() target a specific row using .eq('id', id) to match the product's primary key.
- notifyListeners() tells any widget watching this provider to rebuild with the updated data.
- Save lib/providers/product_provider.dart.
You won't see a visible change yet. The next substep builds the screen that uses this provider.
Seeing a red underline on the import?
If VS Code shows an error on import '../models/product.dart', make sure you created product.dart inside a models folder (not directly in lib/). The file path must be lib/models/product.dart.
Help me fix my import error in product_provider.dart
Build the products screen
Now you need a screen that displays all products in a scrollable list. Each product card shows its name, stock quantity, buy price, and sell price. A floating action button lets staff add new products.
- Create a new file called products_screen.dart inside lib/screens/ and add the following code:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/product_provider.dart';
import '../models/product.dart';
class ProductsScreen extends StatefulWidget {
const ProductsScreen({super.key});
@override
State<ProductsScreen> createState() => _ProductsScreenState();
}
class _ProductsScreenState extends State<ProductsScreen> {
@override
void initState() {
super.initState();
// Fetch products from Supabase when the screen loads
Future.microtask(() {
context.read<ProductProvider>().fetchProducts();
});
}
// Shows a dialog to add a new product or edit an existing one
void _showProductDialog({Product? product}) {
final nameController = TextEditingController(text: product?.name ?? '');
final quantityController = TextEditingController(
text: product?.quantity.toString() ?? '',
);
final buyPriceController = TextEditingController(
text: product?.buyPrice.toString() ?? '',
);
final sellPriceController = TextEditingController(
text: product?.sellPrice.toString() ?? '',
);
final formKey = GlobalKey<FormState>();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(product == null ? 'Add Product' : 'Edit Product'),
content: SingleChildScrollView(
child: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Product Name'),
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
const SizedBox(height: 8),
TextFormField(
controller: quantityController,
decoration: const InputDecoration(labelText: 'Quantity'),
keyboardType: TextInputType.number,
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
const SizedBox(height: 8),
TextFormField(
controller: buyPriceController,
decoration: const InputDecoration(labelText: 'Buy Price'),
keyboardType: TextInputType.number,
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
const SizedBox(height: 8),
TextFormField(
controller: sellPriceController,
decoration: const InputDecoration(labelText: 'Sell Price'),
keyboardType: TextInputType.number,
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () async {
if (!formKey.currentState!.validate()) return;
final newProduct = Product(
name: nameController.text.trim(),
quantity: int.parse(quantityController.text.trim()),
buyPrice: double.parse(buyPriceController.text.trim()),
sellPrice: double.parse(sellPriceController.text.trim()),
);
final provider = context.read<ProductProvider>();
if (product == null) {
await provider.addProduct(newProduct);
} else {
await provider.updateProduct(product.id!, newProduct);
}
if (ctx.mounted) Navigator.pop(ctx);
},
child: Text(product == null ? 'Add' : 'Update'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final currencyFormat = NumberFormat('#,##0.00');
return Scaffold(
body: Consumer<ProductProvider>(
builder: (context, productProvider, _) {
if (productProvider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (productProvider.products.isEmpty) {
return const Center(
child: Text('No products yet. Tap + to add one.'),
);
}
return RefreshIndicator(
onRefresh: () => productProvider.fetchProducts(),
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: productProvider.products.length,
itemBuilder: (context, index) {
final product = productProvider.products[index];
return Card(
child: ListTile(
title: Text(
product.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'Qty: ${product.quantity} | '
'Buy: \u20B1${currencyFormat.format(product.buyPrice)} | '
'Sell: \u20B1${currencyFormat.format(product.sellPrice)}',
),
trailing: PopupMenuButton(
itemBuilder: (ctx) => [
const PopupMenuItem(
value: 'edit',
child: Text('Edit'),
),
const PopupMenuItem(
value: 'delete',
child: Text('Delete'),
),
],
onSelected: (value) async {
if (value == 'edit') {
_showProductDialog(product: product);
} else if (value == 'delete') {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Product'),
content: Text(
'Are you sure you want to delete ${product.name}?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
child: const Text('Delete'),
),
],
),
);
if (confirm == true) {
await productProvider.deleteProduct(product.id!);
}
}
},
),
),
);
},
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showProductDialog(),
backgroundColor: Colors.green,
child: const Icon(Icons.add, color: Colors.white),
),
);
}
}
What does this screen do?
- initState() calls fetchProducts() as soon as the screen loads so the list is always fresh.
- Consumer<ProductProvider> listens for changes and rebuilds the list automatically when products are added, edited, or deleted.
- _showProductDialog() handles both adding and editing. When called without a product parameter, it shows an empty form for adding. When called with an existing product, it pre-fills the fields for editing.
- The PopupMenuButton on each card gives staff quick access to Edit and Delete. Delete shows a confirmation dialog before removing the product.
- Save lib/screens/products_screen.dart.
Seeing errors in VS Code?
If you see red underlines on imports, make sure the file is saved in the correct location: lib/screens/products_screen.dart. Also confirm that lib/models/product.dart and lib/providers/product_provider.dart both exist and are saved.
Help me fix import errors in my products screen
Test the add, edit, and delete flow
Your product management screen is complete. Time to run the app and verify that all four CRUD operations work end to end.
- Run your app on the Android emulator by pressing F5 in VS Code (or running flutter run in your terminal).
- Sign in with your test account credentials.
- Tap the Products tab in the bottom navigation bar.
- You should see the message "No products yet. Tap + to add one." since your database is empty.
- Tap the green + floating action button in the bottom right.
- Fill in the dialog fields: enter Bottled Water for the name, 50 for quantity, 8 for buy price, and 15 for sell price.
- Tap Add.
You should see "Bottled Water" appear in the products list with its quantity and prices displayed.
- Tap the three-dot menu icon on the right side of the Bottled Water card.
- Select Edit and change the quantity to 100, then tap Update.
- Verify the card now shows "Qty: 100" in the subtitle.
- Tap the three-dot menu again and select Delete.
- A confirmation dialog appears asking if you're sure. Tap Delete to confirm.
- The product disappears from the list, confirming the delete operation works.
Why does the list refresh automatically?
Every CRUD method in ProductProvider calls notifyListeners() after completing the database operation. The Consumer<ProductProvider> widget in your screen listens for these notifications and automatically rebuilds the ListView.
Products not appearing after tapping Add?
- Check your Supabase dashboard. Open the Table Editor and look at the products table. If rows appear there but not in your app, the issue is in the fetch logic.
- If no rows appear in the Supabase table, check that your RLS policies are set correctly. Authenticated users need INSERT permission on the products table.
- Look at your VS Code debug console for any error messages starting with "Error adding product" or "Error fetching products".
Help me debug why products are not saving to Supabase
✔️ Awesome, I've got everything!
Great. Double check that all three new files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file created in this step.
class Product {
final int? id;
final String name;
final int quantity;
final double buyPrice;
final double sellPrice;
final DateTime? createdAt;
Product({
this.id,
required this.name,
required this.quantity,
required this.buyPrice,
required this.sellPrice,
this.createdAt,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'] as int?,
name: json['name'] as String,
quantity: json['quantity'] as int,
buyPrice: (json['buy_price'] as num).toDouble(),
sellPrice: (json['sell_price'] as num).toDouble(),
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'quantity': quantity,
'buy_price': buyPrice,
'sell_price': sellPrice,
};
}
}
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/product.dart';
class ProductProvider extends ChangeNotifier {
final _supabase = Supabase.instance.client;
List<Product> _products = [];
bool _isLoading = false;
List<Product> get products => _products;
bool get isLoading => _isLoading;
Future<void> fetchProducts() async {
_isLoading = true;
notifyListeners();
try {
final data = await _supabase
.from('products')
.select()
.order('created_at', ascending: false);
_products = (data as List)
.map((item) => Product.fromJson(item))
.toList();
} catch (e) {
debugPrint('Error fetching products: $e');
}
_isLoading = false;
notifyListeners();
}
Future<void> addProduct(Product product) async {
try {
await _supabase.from('products').insert(product.toJson());
await fetchProducts();
} catch (e) {
debugPrint('Error adding product: $e');
}
}
Future<void> updateProduct(int id, Product product) async {
try {
await _supabase
.from('products')
.update(product.toJson())
.eq('id', id);
await fetchProducts();
} catch (e) {
debugPrint('Error updating product: $e');
}
}
Future<void> deleteProduct(int id) async {
try {
await _supabase.from('products').delete().eq('id', id);
await fetchProducts();
} catch (e) {
debugPrint('Error deleting product: $e');
}
}
}
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/product_provider.dart';
import '../models/product.dart';
class ProductsScreen extends StatefulWidget {
const ProductsScreen({super.key});
@override
State<ProductsScreen> createState() => _ProductsScreenState();
}
class _ProductsScreenState extends State<ProductsScreen> {
@override
void initState() {
super.initState();
Future.microtask(() {
context.read<ProductProvider>().fetchProducts();
});
}
void _showProductDialog({Product? product}) {
final nameController = TextEditingController(text: product?.name ?? '');
final quantityController = TextEditingController(
text: product?.quantity.toString() ?? '',
);
final buyPriceController = TextEditingController(
text: product?.buyPrice.toString() ?? '',
);
final sellPriceController = TextEditingController(
text: product?.sellPrice.toString() ?? '',
);
final formKey = GlobalKey<FormState>();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(product == null ? 'Add Product' : 'Edit Product'),
content: SingleChildScrollView(
child: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Product Name'),
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
const SizedBox(height: 8),
TextFormField(
controller: quantityController,
decoration: const InputDecoration(labelText: 'Quantity'),
keyboardType: TextInputType.number,
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
const SizedBox(height: 8),
TextFormField(
controller: buyPriceController,
decoration: const InputDecoration(labelText: 'Buy Price'),
keyboardType: TextInputType.number,
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
const SizedBox(height: 8),
TextFormField(
controller: sellPriceController,
decoration: const InputDecoration(labelText: 'Sell Price'),
keyboardType: TextInputType.number,
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () async {
if (!formKey.currentState!.validate()) return;
final newProduct = Product(
name: nameController.text.trim(),
quantity: int.parse(quantityController.text.trim()),
buyPrice: double.parse(buyPriceController.text.trim()),
sellPrice: double.parse(sellPriceController.text.trim()),
);
final provider = context.read<ProductProvider>();
if (product == null) {
await provider.addProduct(newProduct);
} else {
await provider.updateProduct(product.id!, newProduct);
}
if (ctx.mounted) Navigator.pop(ctx);
},
child: Text(product == null ? 'Add' : 'Update'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final currencyFormat = NumberFormat('#,##0.00');
return Scaffold(
body: Consumer<ProductProvider>(
builder: (context, productProvider, _) {
if (productProvider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (productProvider.products.isEmpty) {
return const Center(
child: Text('No products yet. Tap + to add one.'),
);
}
return RefreshIndicator(
onRefresh: () => productProvider.fetchProducts(),
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: productProvider.products.length,
itemBuilder: (context, index) {
final product = productProvider.products[index];
return Card(
child: ListTile(
title: Text(
product.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'Qty: ${product.quantity} | '
'Buy: \u20B1${currencyFormat.format(product.buyPrice)} | '
'Sell: \u20B1${currencyFormat.format(product.sellPrice)}',
),
trailing: PopupMenuButton(
itemBuilder: (ctx) => [
const PopupMenuItem(
value: 'edit',
child: Text('Edit'),
),
const PopupMenuItem(
value: 'delete',
child: Text('Delete'),
),
],
onSelected: (value) async {
if (value == 'edit') {
_showProductDialog(product: product);
} else if (value == 'delete') {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Product'),
content: Text(
'Are you sure you want to delete ${product.name}?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
child: const Text('Delete'),
),
],
),
);
if (confirm == true) {
await productProvider.deleteProduct(product.id!);
}
}
},
),
),
);
},
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showProductDialog(),
backgroundColor: Colors.green,
child: const Icon(Icons.add, color: Colors.white),
),
);
}
}
Your product management system is fully operational. Next up, you'll build the order processing screen that uses these products to create customer orders and automatically deduct stock.
Build Order Processing
Your product management system is live and staff can add, edit, and delete items from inventory.
Now you need a way to actually sell those products. The order processing screen lets staff select items, adjust quantities, and check out. Behind the scenes, the app computes totals, calculates profit per transaction, and automatically deducts stock from each product.
In this step, get ready to:
- Create the OrderProvider with cart management and order submission logic.
- Build the order screen UI with a product list and cart panel.
- Implement the checkout flow that saves orders to the database and deducts stock.
Create the order provider with cart management
The order provider manages a shopping cart in memory. Staff tap products to add them, adjust quantities, and the provider computes running totals for the sale amount and profit.
- Create a new file at lib/providers/order_provider.dart.
- Add the imports and the CartItem class by pasting this code:
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/product.dart';
// Represents one product in the cart with a quantity
class CartItem {
final Product product;
int quantity;
CartItem({required this.product, this.quantity = 1});
// Calculate the sale subtotal for this cart item
double get subtotal => product.sellPrice * quantity;
// Calculate profit (sell minus buy, times quantity)
double get profit => (product.sellPrice - product.buyPrice) * quantity;
}
What does this code do?
The CartItem class wraps a Product with a quantity. It exposes two computed getters.
- subtotal gives the customer-facing total (sell price times quantity).
- profit gives the canteen's margin (difference between sell and buy price, times quantity).
- Below the CartItem class, add the OrderProvider class with cart manipulation methods:
class OrderProvider extends ChangeNotifier {
final _supabase = Supabase.instance.client;
final List<CartItem> _cartItems = [];
bool _isProcessing = false;
// Public getters for UI consumption
List<CartItem> get cartItems => _cartItems;
bool get isProcessing => _isProcessing;
// Compute running totals across all cart items
double get totalAmount =>
_cartItems.fold(0.0, (sum, item) => sum + item.subtotal);
double get totalProfit =>
_cartItems.fold(0.0, (sum, item) => sum + item.profit);
// Add a product to cart (increment if already present)
void addToCart(Product product) {
final existingIndex = _cartItems.indexWhere(
(item) => item.product.id == product.id,
);
if (existingIndex >= 0) {
_cartItems[existingIndex].quantity++;
} else {
_cartItems.add(CartItem(product: product));
}
notifyListeners();
}
// Remove a product from cart entirely
void removeFromCart(int productId) {
_cartItems.removeWhere((item) => item.product.id == productId);
notifyListeners();
}
// Update the quantity for a specific product
void updateQuantity(int productId, int quantity) {
final index = _cartItems.indexWhere(
(item) => item.product.id == productId,
);
if (index >= 0) {
if (quantity <= 0) {
_cartItems.removeAt(index);
} else {
_cartItems[index].quantity = quantity;
}
notifyListeners();
}
}
// Empty the cart
void clearCart() {
_cartItems.clear();
notifyListeners();
}
}
What does this code do?
The OrderProvider holds a private list of cart items and exposes methods to manipulate it.
- addToCart checks if the product is already in the cart. If so, it increments the quantity instead of adding a duplicate.
- totalAmount and totalProfit use fold to sum across all items in real time.
- Every mutation calls notifyListeners() so the UI rebuilds automatically.
- Save order_provider.dart.
You won't see a change yet. The submitOrder() method and order screen are coming next.
Seeing import errors?
Make sure lib/models/product.dart exists from the previous step. The import path ../models/product.dart must match your folder structure exactly.
help me fix import errors in my order_provider.dart file
Build the order screen
The order screen has two sections. The top half shows all in-stock products that staff can tap to add to the cart. The bottom half is a cart panel that appears once items are selected.
- Create a new file at lib/screens/order_screen.dart.
- Add the imports, class declaration, and product list by pasting this code:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/product_provider.dart';
import '../providers/order_provider.dart';
import '../providers/dashboard_provider.dart';
class OrderScreen extends StatefulWidget {
const OrderScreen({super.key});
@override
State<OrderScreen> createState() => _OrderScreenState();
}
class _OrderScreenState extends State<OrderScreen> {
@override
void initState() {
super.initState();
// Fetch fresh product data when the screen loads
Future.microtask(() {
context.read<ProductProvider>().fetchProducts();
});
}
@override
Widget build(BuildContext context) {
final currencyFormat = NumberFormat('#,##0.00');
return Scaffold(
body: Column(
children: [
// Top section: scrollable list of available products
Expanded(
child: Consumer<ProductProvider>(
builder: (context, productProvider, _) {
if (productProvider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
// Only show products that have stock remaining
final availableProducts = productProvider.products
.where((p) => p.quantity > 0)
.toList();
if (availableProducts.isEmpty) {
return const Center(
child: Text('No products available for ordering.'),
);
}
return ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: availableProducts.length,
itemBuilder: (context, index) {
final product = availableProducts[index];
return Card(
child: ListTile(
title: Text(product.name),
subtitle: Text(
'\u20B1${currencyFormat.format(product.sellPrice)} | '
'Stock: ${product.quantity}',
),
trailing: IconButton(
icon: const Icon(Icons.add_circle,
color: Colors.green, size: 32),
onPressed: () {
context.read<OrderProvider>().addToCart(product);
},
),
),
);
},
);
},
),
),
What does this code do?
The top half of the screen fetches all products and filters to only those with quantity > 0. Each product card shows its sell price and remaining stock.
The green add_circle icon button calls addToCart(product) on the OrderProvider. Tapping it multiple times increments the quantity rather than duplicating the item.
- Directly below the code you just pasted (still inside the Column's children list), add the cart panel:
// Bottom section: cart panel (hidden when empty)
Consumer<OrderProvider>(
builder: (context, orderProvider, _) {
if (orderProvider.cartItems.isEmpty) {
return const SizedBox.shrink();
}
return Container(
decoration: BoxDecoration(
color: Colors.grey[100],
border: const Border(
top: BorderSide(color: Colors.grey),
),
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Cart',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 150),
child: ListView.builder(
shrinkWrap: true,
itemCount: orderProvider.cartItems.length,
itemBuilder: (context, index) {
final item = orderProvider.cartItems[index];
return Row(
children: [
Expanded(child: Text(item.product.name)),
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: () {
orderProvider.updateQuantity(
item.product.id!,
item.quantity - 1,
);
},
),
Text('${item.quantity}'),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () {
if (item.quantity < item.product.quantity) {
orderProvider.updateQuantity(
item.product.id!,
item.quantity + 1,
);
}
},
),
Text(
'\u20B1${currencyFormat.format(item.subtotal)}',
style: const TextStyle(
fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.delete,
color: Colors.red, size: 20),
onPressed: () {
orderProvider.removeFromCart(
item.product.id!);
},
),
],
);
},
),
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Total: \u20B1${currencyFormat.format(orderProvider.totalAmount)}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
ElevatedButton(
onPressed: orderProvider.isProcessing
? null
: () async {
final success =
await orderProvider.submitOrder();
if (success && mounted) {
context
.read<ProductProvider>()
.fetchProducts();
context
.read<DashboardProvider>()
.fetchDashboardData();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Order placed successfully!'),
backgroundColor: Colors.green,
),
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
child: orderProvider.isProcessing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text('Checkout'),
),
],
),
],
),
);
},
),
],
),
);
}
}
What does this code do?
The cart panel only appears when there are items in the cart (otherwise it returns SizedBox.shrink()).
- Each cart row shows the product name, minus/plus buttons to adjust quantity, the subtotal, and a delete icon.
- The add_circle_outline button caps at the product's available stock to prevent overselling.
- The Checkout button calls submitOrder(), then refreshes both the ProductProvider and DashboardProvider so the rest of the app reflects the sale.
- Save order_screen.dart.
The screen is ready, but the Checkout button calls submitOrder() which you have not written yet. That is the next substep.
Seeing a red underline on submitOrder?
This is expected. The method does not exist in your OrderProvider yet. You will add it in the next substep and the error will disappear.
help me understand why my order screen shows an error on submitOrder
✔️ Awesome, I've got everything!
Great. Make sure you have saved order_screen.dart before moving on.
ⓧ I'd like to double check the full code
Here is the complete order_screen.dart file:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/product_provider.dart';
import '../providers/order_provider.dart';
import '../providers/dashboard_provider.dart';
class OrderScreen extends StatefulWidget {
const OrderScreen({super.key});
@override
State<OrderScreen> createState() => _OrderScreenState();
}
class _OrderScreenState extends State<OrderScreen> {
@override
void initState() {
super.initState();
Future.microtask(() {
context.read<ProductProvider>().fetchProducts();
});
}
@override
Widget build(BuildContext context) {
final currencyFormat = NumberFormat('#,##0.00');
return Scaffold(
body: Column(
children: [
Expanded(
child: Consumer<ProductProvider>(
builder: (context, productProvider, _) {
if (productProvider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final availableProducts = productProvider.products
.where((p) => p.quantity > 0)
.toList();
if (availableProducts.isEmpty) {
return const Center(
child: Text('No products available for ordering.'),
);
}
return ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: availableProducts.length,
itemBuilder: (context, index) {
final product = availableProducts[index];
return Card(
child: ListTile(
title: Text(product.name),
subtitle: Text(
'\u20B1${currencyFormat.format(product.sellPrice)} | '
'Stock: ${product.quantity}',
),
trailing: IconButton(
icon: const Icon(Icons.add_circle,
color: Colors.green, size: 32),
onPressed: () {
context.read<OrderProvider>().addToCart(product);
},
),
),
);
},
);
},
),
),
Consumer<OrderProvider>(
builder: (context, orderProvider, _) {
if (orderProvider.cartItems.isEmpty) {
return const SizedBox.shrink();
}
return Container(
decoration: BoxDecoration(
color: Colors.grey[100],
border: const Border(
top: BorderSide(color: Colors.grey),
),
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Cart',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 150),
child: ListView.builder(
shrinkWrap: true,
itemCount: orderProvider.cartItems.length,
itemBuilder: (context, index) {
final item = orderProvider.cartItems[index];
return Row(
children: [
Expanded(child: Text(item.product.name)),
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: () {
orderProvider.updateQuantity(
item.product.id!,
item.quantity - 1,
);
},
),
Text('${item.quantity}'),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () {
if (item.quantity < item.product.quantity) {
orderProvider.updateQuantity(
item.product.id!,
item.quantity + 1,
);
}
},
),
Text(
'\u20B1${currencyFormat.format(item.subtotal)}',
style: const TextStyle(
fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.delete,
color: Colors.red, size: 20),
onPressed: () {
orderProvider.removeFromCart(
item.product.id!);
},
),
],
);
},
),
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Total: \u20B1${currencyFormat.format(orderProvider.totalAmount)}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
ElevatedButton(
onPressed: orderProvider.isProcessing
? null
: () async {
final success =
await orderProvider.submitOrder();
if (success && mounted) {
context
.read<ProductProvider>()
.fetchProducts();
context
.read<DashboardProvider>()
.fetchDashboardData();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Order placed successfully!'),
backgroundColor: Colors.green,
),
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
child: orderProvider.isProcessing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text('Checkout'),
),
],
),
],
),
);
},
),
],
),
);
}
}
Implement the checkout flow
When staff tap Checkout, the app needs to do three things in sequence: insert a new order record, save each cart item as an order_item row, and deduct the sold quantities from the products table.
- Open lib/providers/order_provider.dart.
- Inside the OrderProvider class, below the clearCart() method, add the submitOrder() method:
// Submit the cart as a completed order to Supabase
Future<bool> submitOrder() async {
if (_cartItems.isEmpty) return false;
_isProcessing = true;
notifyListeners();
try {
// 1. Insert the order header and get back its ID
final orderData = await _supabase
.from('orders')
.insert({
'total_amount': totalAmount,
'total_profit': totalProfit,
})
.select()
.single();
final orderId = orderData['id'] as int;
// 2. Build and insert all order line items
final orderItems = _cartItems.map((item) => {
'order_id': orderId,
'product_id': item.product.id,
'quantity': item.quantity,
'unit_price': item.product.sellPrice,
'subtotal': item.subtotal,
}).toList();
await _supabase.from('order_items').insert(orderItems);
// 3. Deduct sold quantities from each product
for (final item in _cartItems) {
final newQuantity = item.product.quantity - item.quantity;
await _supabase
.from('products')
.update({'quantity': newQuantity})
.eq('id', item.product.id!);
}
clearCart();
_isProcessing = false;
notifyListeners();
return true;
} catch (e) {
debugPrint('Error submitting order: $e');
_isProcessing = false;
notifyListeners();
return false;
}
}
What does this code do?
The submitOrder() method performs three database operations in sequence.
- It inserts a row into the orders table with the total amount and profit, then uses .select().single() to get the auto-generated id back.
- It maps each cart item into an order_items row referencing that order ID, then batch-inserts them all at once.
- It loops through each cart item and updates the product's quantity column to subtract the amount sold.
- If anything fails, it catches the error and returns false so the UI can respond.
- Save order_provider.dart.
- Run your app in the emulator and navigate to the Orders tab in the bottom navigation bar.
- Tap the green + icon on a product to add it to the cart.
- Tap Checkout and confirm you see a green Order placed successfully! snackbar at the bottom.
After checkout, the product list should show reduced stock quantities. Switch to the Products tab to verify the numbers match.
Checkout button does nothing?
Make sure the submitOrder() method is inside the OrderProvider class (before the closing }), not outside it.
Also check your Supabase RLS policies. If you see errors in the debug console mentioning permission denied, revisit Step 2 and confirm the Allow all for authenticated users policy exists on all three tables.
help me debug why my checkout is not working
✔️ Awesome, I've got everything!
Great. Double check you have saved both order_provider.dart and order_screen.dart.
ⓧ I'd like to double check the full code
Here is the complete order_provider.dart:
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/product.dart';
class CartItem {
final Product product;
int quantity;
CartItem({required this.product, this.quantity = 1});
double get subtotal => product.sellPrice * quantity;
double get profit => (product.sellPrice - product.buyPrice) * quantity;
}
class OrderProvider extends ChangeNotifier {
final _supabase = Supabase.instance.client;
final List<CartItem> _cartItems = [];
bool _isProcessing = false;
List<CartItem> get cartItems => _cartItems;
bool get isProcessing => _isProcessing;
double get totalAmount =>
_cartItems.fold(0.0, (sum, item) => sum + item.subtotal);
double get totalProfit =>
_cartItems.fold(0.0, (sum, item) => sum + item.profit);
void addToCart(Product product) {
final existingIndex = _cartItems.indexWhere(
(item) => item.product.id == product.id,
);
if (existingIndex >= 0) {
_cartItems[existingIndex].quantity++;
} else {
_cartItems.add(CartItem(product: product));
}
notifyListeners();
}
void removeFromCart(int productId) {
_cartItems.removeWhere((item) => item.product.id == productId);
notifyListeners();
}
void updateQuantity(int productId, int quantity) {
final index = _cartItems.indexWhere(
(item) => item.product.id == productId,
);
if (index >= 0) {
if (quantity <= 0) {
_cartItems.removeAt(index);
} else {
_cartItems[index].quantity = quantity;
}
notifyListeners();
}
}
void clearCart() {
_cartItems.clear();
notifyListeners();
}
Future<bool> submitOrder() async {
if (_cartItems.isEmpty) return false;
_isProcessing = true;
notifyListeners();
try {
final orderData = await _supabase
.from('orders')
.insert({
'total_amount': totalAmount,
'total_profit': totalProfit,
})
.select()
.single();
final orderId = orderData['id'] as int;
final orderItems = _cartItems.map((item) => {
'order_id': orderId,
'product_id': item.product.id,
'quantity': item.quantity,
'unit_price': item.product.sellPrice,
'subtotal': item.subtotal,
}).toList();
await _supabase.from('order_items').insert(orderItems);
for (final item in _cartItems) {
final newQuantity = item.product.quantity - item.quantity;
await _supabase
.from('products')
.update({'quantity': newQuantity})
.eq('id', item.product.id!);
}
clearCart();
_isProcessing = false;
notifyListeners();
return true;
} catch (e) {
debugPrint('Error submitting order: $e');
_isProcessing = false;
notifyListeners();
return false;
}
}
}
Your canteen app can now process sales from start to finish. Next up, you will build a dashboard that aggregates all these orders into at-a-glance metrics for canteen staff.
Build the Dashboard
Your order processing system is complete. Staff can add items to a cart, check out, and the app automatically deducts stock and records the transaction in Supabase.
Now you need a way to see the big picture. How much has the canteen sold today? What's the profit? Which products are running low? In this step, you will build a dashboard that answers all of these questions at a glance.
In this step, get ready to:
- Create a DashboardProvider that fetches and computes aggregate metrics from Supabase.
- Build the dashboard screen with metric cards in a grid layout.
- Add a low-stock alerts section that flags products needing restocking.
Create the DashboardProvider
The dashboard needs data from two tables: orders (for sales and profit totals) and products (for inventory counts). A dedicated Provider will fetch both and compute the five metrics the dashboard displays.
- Create a new file at lib/providers/dashboard_provider.dart and paste the following code:
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/product.dart';
class DashboardProvider extends ChangeNotifier {
final _supabase = Supabase.instance.client;
double _totalSales = 0;
double _totalProfit = 0;
int _productCount = 0;
int _lowStockCount = 0;
int _transactionCount = 0;
List<Product> _lowStockProducts = [];
bool _isLoading = false;
// Public getters so the UI can read the computed values
double get totalSales => _totalSales;
double get totalProfit => _totalProfit;
int get productCount => _productCount;
int get lowStockCount => _lowStockCount;
int get transactionCount => _transactionCount;
List<Product> get lowStockProducts => _lowStockProducts;
bool get isLoading => _isLoading;
Future<void> fetchDashboardData() async {
_isLoading = true;
notifyListeners();
try {
// Query all orders to compute sales and profit totals
final orders = await _supabase.from('orders').select();
_totalSales = 0;
_totalProfit = 0;
for (final order in orders) {
_totalSales += (order['total_amount'] as num).toDouble();
_totalProfit += (order['total_profit'] as num).toDouble();
}
_transactionCount = orders.length;
// Query all products to count inventory and find low-stock items
final products = await _supabase.from('products').select();
_productCount = products.length;
_lowStockProducts = (products as List)
.where((p) => (p['quantity'] as int) < 10)
.map((p) => Product.fromJson(p))
.toList();
_lowStockCount = _lowStockProducts.length;
} catch (e) {
debugPrint('Error fetching dashboard data: $e');
}
_isLoading = false;
notifyListeners();
}
}
What does this code do?
The fetchDashboardData() method runs two queries against your Supabase database.
- It selects all rows from the orders table and loops through them to sum up total_amount and total_profit.
- It selects all rows from the products table, counts them, and filters for items with quantity below 10 to build the low-stock list.
- The notifyListeners() calls tell any widget listening to this provider to rebuild with the new data.
- Save lib/providers/dashboard_provider.dart.
You will not see a change yet. The next substep builds the screen that displays these metrics.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/product.dart';
class DashboardProvider extends ChangeNotifier {
final _supabase = Supabase.instance.client;
double _totalSales = 0;
double _totalProfit = 0;
int _productCount = 0;
int _lowStockCount = 0;
int _transactionCount = 0;
List<Product> _lowStockProducts = [];
bool _isLoading = false;
double get totalSales => _totalSales;
double get totalProfit => _totalProfit;
int get productCount => _productCount;
int get lowStockCount => _lowStockCount;
int get transactionCount => _transactionCount;
List<Product> get lowStockProducts => _lowStockProducts;
bool get isLoading => _isLoading;
Future<void> fetchDashboardData() async {
_isLoading = true;
notifyListeners();
try {
final orders = await _supabase.from('orders').select();
_totalSales = 0;
_totalProfit = 0;
for (final order in orders) {
_totalSales += (order['total_amount'] as num).toDouble();
_totalProfit += (order['total_profit'] as num).toDouble();
}
_transactionCount = orders.length;
final products = await _supabase.from('products').select();
_productCount = products.length;
_lowStockProducts = (products as List)
.where((p) => (p['quantity'] as int) < 10)
.map((p) => Product.fromJson(p))
.toList();
_lowStockCount = _lowStockProducts.length;
} catch (e) {
debugPrint('Error fetching dashboard data: $e');
}
_isLoading = false;
notifyListeners();
}
}
Build the dashboard screen with metric cards
The dashboard screen serves as the app's main shell. It holds the AppBar, a BottomNavigationBar for switching between Dashboard, Products, and Orders, and the metric grid itself.
- Create a new file at lib/screens/dashboard_screen.dart and paste the following code:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/auth_provider.dart';
import '../providers/dashboard_provider.dart';
import 'login_screen.dart';
import 'products_screen.dart';
import 'order_screen.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
int _currentIndex = 0;
@override
void initState() {
super.initState();
// Fetch dashboard data as soon as the screen loads
Future.microtask(() {
context.read<DashboardProvider>().fetchDashboardData();
});
}
@override
Widget build(BuildContext context) {
// Three screens the bottom nav switches between
final screens = [
const _DashboardView(),
const ProductsScreen(),
const OrderScreen(),
];
return Scaffold(
appBar: AppBar(
title: const Text('CTU Canteen'),
backgroundColor: Colors.green,
foregroundColor: Colors.white,
actions: [
// Logout button in the top-right corner
IconButton(
icon: const Icon(Icons.logout),
onPressed: () async {
await context.read<AuthProvider>().signOut();
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
}
},
),
],
),
body: screens[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
setState(() => _currentIndex = index);
// Refresh dashboard data when switching back to the dashboard tab
if (index == 0) {
context.read<DashboardProvider>().fetchDashboardData();
}
},
selectedItemColor: Colors.green,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.dashboard),
label: 'Dashboard',
),
BottomNavigationBarItem(
icon: Icon(Icons.inventory),
label: 'Products',
),
BottomNavigationBarItem(
icon: Icon(Icons.shopping_cart),
label: 'Orders',
),
],
),
);
}
}
class _DashboardView extends StatelessWidget {
const _DashboardView();
@override
Widget build(BuildContext context) {
final currencyFormat = NumberFormat('#,##0.00');
return Consumer<DashboardProvider>(
builder: (context, dashboard, _) {
if (dashboard.isLoading) {
return const Center(child: CircularProgressIndicator());
}
return RefreshIndicator(
onRefresh: () => dashboard.fetchDashboardData(),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Dashboard',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// 2-column grid of metric cards
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1.5,
children: [
_MetricCard(
title: 'Total Sales',
value: '\u20B1${currencyFormat.format(dashboard.totalSales)}',
icon: Icons.monetization_on,
color: Colors.blue,
),
_MetricCard(
title: 'Total Profit',
value: '\u20B1${currencyFormat.format(dashboard.totalProfit)}',
icon: Icons.trending_up,
color: Colors.green,
),
_MetricCard(
title: 'Products',
value: '${dashboard.productCount}',
icon: Icons.fastfood,
color: Colors.orange,
),
_MetricCard(
title: 'Low Stock',
value: '${dashboard.lowStockCount}',
icon: Icons.warning,
color: Colors.red,
),
_MetricCard(
title: 'Transactions',
value: '${dashboard.transactionCount}',
icon: Icons.receipt_long,
color: Colors.purple,
),
],
),
],
),
),
);
},
);
}
}
// Reusable card widget for each metric
class _MetricCard extends StatelessWidget {
final String title;
final String value;
final IconData icon;
final Color color;
const _MetricCard({
required this.title,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 28),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
title,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
);
}
}
What does this code do?
- DashboardScreen is the app's main scaffold. It uses _currentIndex to switch between three child screens via the bottom navigation bar.
- The initState method triggers fetchDashboardData() immediately so the metrics are ready when the screen appears.
- The logout button calls signOut() on the AuthProvider and navigates back to the login screen.
- _DashboardView uses a Consumer<DashboardProvider> to reactively rebuild when data changes. The RefreshIndicator lets staff pull down to refresh.
- _MetricCard is a reusable widget that displays an icon, a value, and a label inside a Material card.
- Save lib/screens/dashboard_screen.dart.
- Run the app on your emulator by pressing F5 in VS Code or running flutter run in the terminal.
- Sign in with your test account. You should see the dashboard with five metric cards in a 2-column grid, all showing zeroes (since no orders exist yet).
Seeing a red error screen?
- Check that you imported products_screen.dart and order_screen.dart at the top of your dashboard screen file. Missing imports cause compile errors.
- Confirm the file name matches exactly: dashboard_provider.dart in the lib/providers/ folder. The import in main.dart expects this path.
- If you see a RenderFlex overflowed warning, make sure the GridView.count has shrinkWrap: true and NeverScrollableScrollPhysics().
Help me debug my dashboard screen
Add low-stock alerts below the metrics
Staff need a quick way to see which products are running out. You will add a section below the metric grid that lists every product with fewer than 10 units in stock.
- In lib/screens/dashboard_screen.dart, find the closing bracket of the GridView.count widget inside the _DashboardView class. It is the last item in the Column's children list.
- Add the following code after the GridView.count widget, still inside the Column's children list:
const SizedBox(height: 24),
const Text(
'Low Stock Alerts',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
// Show a success message or a list of low-stock products
if (dashboard.lowStockProducts.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('All products are well-stocked!'),
),
)
else
...dashboard.lowStockProducts.map((product) => Card(
child: ListTile(
leading: const Icon(Icons.warning, color: Colors.red),
title: Text(product.name),
subtitle: Text('Only ${product.quantity} left'),
trailing: Text(
'\u20B1${currencyFormat.format(product.sellPrice)}',
),
),
)),
What does this code do?
- The if/else inside the Column uses Dart's collection-if syntax. If there are no low-stock products, it shows a reassuring card. Otherwise, it spreads the list of warning cards into the Column.
- Each low-stock card displays a warning icon, the product name, the remaining quantity, and its sell price so staff know at a glance what to restock.
- The threshold is quantity below 10. You set this in the DashboardProvider with the .where((p) => (p['quantity'] as int) < 10) filter.
- Save lib/screens/dashboard_screen.dart.
- Hot-reload the app (press r in the terminal where flutter run is running). You should see a Low Stock Alerts heading below the metric grid with an All products are well-stocked! card (since you have not added products with quantity below 10 yet).
Test the low-stock alerts
Switch to the Products tab, add a product with a quantity of 5, then switch back to Dashboard. You should see that product appear in the Low Stock Alerts list with a red warning icon.
Low-stock section not appearing?
- Make sure you pasted the new code INSIDE the Column's children: [...] list, after the GridView.count(...) closing parenthesis and comma.
- Check that the spread operator ... is present before dashboard.lowStockProducts.map(...). Without it, Dart will throw a type error because you are inserting an Iterable where a Widget is expected.
Help me fix my low-stock alerts section
✔️ Awesome, I've got everything!
Great. Double check that you have saved both files before continuing.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/auth_provider.dart';
import '../providers/dashboard_provider.dart';
import 'login_screen.dart';
import 'products_screen.dart';
import 'order_screen.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
int _currentIndex = 0;
@override
void initState() {
super.initState();
Future.microtask(() {
context.read<DashboardProvider>().fetchDashboardData();
});
}
@override
Widget build(BuildContext context) {
final screens = [
const _DashboardView(),
const ProductsScreen(),
const OrderScreen(),
];
return Scaffold(
appBar: AppBar(
title: const Text('CTU Canteen'),
backgroundColor: Colors.green,
foregroundColor: Colors.white,
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () async {
await context.read<AuthProvider>().signOut();
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
}
},
),
],
),
body: screens[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
setState(() => _currentIndex = index);
if (index == 0) {
context.read<DashboardProvider>().fetchDashboardData();
}
},
selectedItemColor: Colors.green,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.dashboard),
label: 'Dashboard',
),
BottomNavigationBarItem(
icon: Icon(Icons.inventory),
label: 'Products',
),
BottomNavigationBarItem(
icon: Icon(Icons.shopping_cart),
label: 'Orders',
),
],
),
);
}
}
class _DashboardView extends StatelessWidget {
const _DashboardView();
@override
Widget build(BuildContext context) {
final currencyFormat = NumberFormat('#,##0.00');
return Consumer<DashboardProvider>(
builder: (context, dashboard, _) {
if (dashboard.isLoading) {
return const Center(child: CircularProgressIndicator());
}
return RefreshIndicator(
onRefresh: () => dashboard.fetchDashboardData(),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Dashboard',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1.5,
children: [
_MetricCard(
title: 'Total Sales',
value: '\u20B1${currencyFormat.format(dashboard.totalSales)}',
icon: Icons.monetization_on,
color: Colors.blue,
),
_MetricCard(
title: 'Total Profit',
value: '\u20B1${currencyFormat.format(dashboard.totalProfit)}',
icon: Icons.trending_up,
color: Colors.green,
),
_MetricCard(
title: 'Products',
value: '${dashboard.productCount}',
icon: Icons.fastfood,
color: Colors.orange,
),
_MetricCard(
title: 'Low Stock',
value: '${dashboard.lowStockCount}',
icon: Icons.warning,
color: Colors.red,
),
_MetricCard(
title: 'Transactions',
value: '${dashboard.transactionCount}',
icon: Icons.receipt_long,
color: Colors.purple,
),
],
),
const SizedBox(height: 24),
const Text(
'Low Stock Alerts',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
if (dashboard.lowStockProducts.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('All products are well-stocked!'),
),
)
else
...dashboard.lowStockProducts.map((product) => Card(
child: ListTile(
leading: const Icon(Icons.warning, color: Colors.red),
title: Text(product.name),
subtitle: Text('Only ${product.quantity} left'),
trailing: Text(
'\u20B1${currencyFormat.format(product.sellPrice)}',
),
),
)),
],
),
),
);
},
);
}
}
class _MetricCard extends StatelessWidget {
final String title;
final String value;
final IconData icon;
final Color color;
const _MetricCard({
required this.title,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 28),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
title,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
);
}
}
Secret mission
Add Sales and Profit Reports with Date Filtering
Your canteen manager wants to see how sales and profit trend over time. Build a reports screen that lets staff filter orders by date range and view daily or monthly summaries.
Clean Up Your Resources
Clean Up Your Resources
This project runs on an Android emulator with a Supabase free tier backend. There are no ongoing costs, but you may want to tidy up your local files or Supabase project depending on your plans.
Resources you used:
- Flutter project folder (ctu_canteen) on your Desktop.
- Supabase project with PostgreSQL database (products, orders, order_items tables).
- Supabase authentication configuration and user accounts.
- Android emulator in Android Studio.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Keep everything running
No action needed. Choose this if you're still actively building or want to extend the app with the Secret Mission.
- Your Supabase project stays active on the free tier with no charges.
- Your Flutter project folder remains on your Desktop ready to open in VS Code.
- Your Android emulator stays available in Android Studio.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and Supabase project remain intact for when you return.
- Stop the Flutter debug session by pressing the stop button in VS Code or closing the emulator.
- Close the Android emulator to free up RAM.
- Your Supabase project will stay available on the free tier indefinitely. No action needed there.
- When you return, open the ctu_canteen folder in VS Code and launch the emulator again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- Delete the ctu_canteen folder from your Desktop.
- Go to your Supabase dashboard, open your project's Settings page, scroll to the bottom, and click Delete project.
- Close and shut down the Android emulator in Android Studio.
Nice Work!
Nice Work!
Nice work! You just built a complete Electronic Canteen Management System from scratch using Flutter and Supabase.
You've learned how to:
- Build a Flutter mobile app with Supabase authentication, a PostgreSQL database, and Provider state management.
- Create a full CRUD product management system with buy/sell price tracking and an order processing flow that auto-computes totals, calculates profit, and deducts inventory.
- Construct a dashboard with real-time aggregate metrics including total sales, total profit, product count, and low-stock alerts.
- Secret Mission: Added date-filtered sales and profit reports with daily and monthly analysis.
Ready to quiz yourself?