使用 Supabase 在 Flutter 中构建一个简单的杂货店应用程序介绍演示数据库设计关系 Supabase 设置创建表 Flutter 数据模型 Flutter 设置 Supabase 查询身份验证 Supabase 服务摘要

2025-06-08

使用 Supabase 在 Flutter 中构建简单的杂货店应用程序

介绍

演示

数据库设计

关系

Supabase 设置

创建表

Flutter 数据模型

Flutter 设置

Supabase 查询

验证

Supabase 服务




概括

介绍

在本文中,我将向您展示如何使用 Flutter 构建一个以 Supabase 为后端的应用程序。Supabase 是 Firebase 的替代品,它使用 Postgres,这很独特,甚至令人惊叹。Postgres 也是一个关系数据库,同样是开源的,并且功能强大。

我们将学习并使用 Flutter 和 Supabase 构建一个简单的杂货应用程序。

我不会一步一步地介绍如何设置 Flutter,因为我的构建方式使用了遵循 MVVM 风格的 Stacked 架构,所以我只会向您展示如何在 Flutter 应用程序中用 Dart 编写 Supabase 代码。

您可以在FilledStacks上了解有关 Stacked 架构的更多信息:D

我的 SupaGrocery 仓库将在本教程的最后分享。你可以直接下载。

演示

SupaGrocery 演示

数据库设计

在开始其他操作之前,我们先来看看数据库设计。见下方附图。

图像

对于我们的简单杂货应用程序,我们只需要 4 个这样的表。

  • app_usersusers:这是我们存储用户的表,它将与 supabase auth 用户具有相同的主 ID。由于无法公开读取,我无法仅使用这个表,因此我必须创建此表。
  • groceries:每个用户的所有购物清单都将存储在此表中。
  • products:用户创建的所有商品都会存储在这张表中。
  • grocery_products:我们在这里将产品与杂货店联系起来。这就是我们所说的数据透视表。

关系

在关系数据库中,表关系是非常常见的事情,也是我在关系数据库中最喜欢的东西。

这两种是最常见的关系:

  • 一对一
  • 一对多
  • 多对多(数据透视表)

我们的app_users表与我们创建的两个表具有一对多关系productsgroceries因为用户可以拥有许多购物清单,并且在该购物清单中也可以拥有许多产品。

然后,对于我们的groceries表,我们将该created_by列作为外键,以便链接到该app_users表,然后将其标识为我们应用程序中用户杂货清单的一部分。

products对于以列作为外键的表created_by也是如此。

然后对于我们的数据透视表来说,它是一个多对多关系,因为一个杂货店清单可以有许多产品,而一个产品可以属于许多杂货店清单。

Supabase 设置

创建你的第一个 Supabase 帐户!前往他们的官方网站https://supabase.io/ 。

应该带你去这个精彩的黑暗主题网站:D

图像

现在继续并单击该按钮“开始您的项目”

它将显示此 auth0 页面,因此只需继续使用 GitHub 即可立即注册!

图像

然后只需使用您的 GitHub 凭据登录。

图像

创建完第一个帐户后,您可能已经进入了仪表板,其中列出了您在 Supabase 中创建的所有项目。

图像

现在点击“新建项目”,然后选择您想要的组织。我选择的是修改后的“个人”。

进入此页面后,只需填写以下字段:

图像

名称:“Grocery App”
数据库密码:“s0m3Str0ng_PassWord!@#”(您应该使用自己的密码)
地区:(选择您附近的任何东西)

完成后单击“创建新项目”!

图像

然后它会将您重定向到此页面。

图像

这将需要几分钟,请等待:)

创建表

当 Supabase 设置完毕,并且您创建了新项目后,它将带您进入此页面。

图像

现在让我们点击“创建新表”

我们将提供数据库设计中的所有细节,因此这个设置应该非常快。

图像

我的建议是取消勾选“包含主键”,稍后在创建表时再添加主键。这里面有个 bug,导致我无法为该uuid键设置默认值,只能uuid在创建新记录时自动生成一个。

图像

然后点击右上角的“保存”即可最终创建表格。

创建该表后,我们可以继续添加主键,即uuid。单击该加号图标可为表添加新列。

图像

然后将该列命名为id,它将成为主键和类型,uuid然后具有默认值“自动生成 UUID”,完成后单击“保存”。

图像

完成后,我们可以继续创建更多我们从数据库设计中定义的列。

图像

接下来,我们将创建一个表,products并为此表设置外键,因为每个产品都属于一个用户。我们将快速学习如何操作。

因此,假设您已经创建了主键id及其对应的namevarchar 列,让我们创建最后一个字段created_by并将其设置为与表链接的外键app_users

图像

现在点击底部的“添加外键关系”按钮

图像

然后选择表app_usersid字段,完成后单击“保存”

然后应该向您显示它现在已与表格链接app_users,这非常令人惊奇。

图像

这就是设置外键所需了解的全部内容。现在,剩下的表就交给你了。你懂的!

Flutter 数据模型

freezed我们将使用包来设置我们的数据模型,json_serializable并确保builder_runner在您的项目中有一个设置。

以下是我们的应用程序数据模型



import 'package:freezed_annotation/freezed_annotation.dart';

part 'application_models.freezed.dart';
part 'application_models.g.dart';

@freezed
class AppUser with _$AppUser {
  const factory AppUser({
    required String id,
    required String name,
    required String email,
  }) = _AppUser;

  factory AppUser.fromJson(Map<String, dynamic> json) =>
      _$AppUserFromJson(json);
}

@freezed
class Grocery with _$Grocery {
  const Grocery._();
  const factory Grocery({
    required String id,
    required String name,
    @JsonKey(name: 'created_by')
        required String createdBy,
    @Default([])
    @JsonKey(
      name: 'grocery_products',
      fromJson: Grocery._productsFromJson,
      toJson: Grocery._productsToJson,
    )
        List<GroceryProduct>? groceryProducts,
  }) = _Grocery;

  bool get hasGroceryProducts => groceryProducts!.length > 0;

  List<Product?>? get products {
    if (!hasGroceryProducts) return [];

    return groceryProducts!.map((e) => e.product).toList();
  }

  factory Grocery.fromJson(Map<String, dynamic> json) =>
      _$GroceryFromJson(json);

  static List<GroceryProduct>? _productsFromJson(List<dynamic>? list) {
    if (list == null) {
      return [];
    }

    return list.map((e) => GroceryProduct.fromJson(e)).toList();
  }

  static List<Map<String, dynamic>>? _productsToJson(
      List<GroceryProduct>? list) {
    if (list == null) {
      return [];
    }

    return list.map((e) => e.toJson()).toList();
  }
}

@freezed
class GroceryDto with _$GroceryDto {
  const factory GroceryDto({
    required String name,
    @JsonKey(name: 'created_by') required String createdBy,
  }) = _GroceryDto;

  factory GroceryDto.fromJson(Map<String, dynamic> json) =>
      _$GroceryDtoFromJson(json);
}

@freezed
class Product with _$Product {
  const factory Product({
    required String id,
    required String name,
    @JsonKey(name: 'created_by') required String createdBy,
  }) = _Product;

  factory Product.fromJson(Map<String, dynamic> json) =>
      _$ProductFromJson(json);
}

@freezed
class ProductDto with _$ProductDto {
  const factory ProductDto({
    required String name,
    @JsonKey(name: 'created_by') required String createdBy,
  }) = _ProductDto;

  factory ProductDto.fromJson(Map<String, dynamic> json) =>
      _$ProductDtoFromJson(json);
}

@freezed
class GroceryProduct with _$GroceryProduct {
  const factory GroceryProduct({
    required String id,
    @JsonKey(name: 'grocery_id') required String groceryId,
    @JsonKey(name: 'product_id') required String productId,
    required int quantity,
    @JsonKey(name: 'products') Product? product,
    @Default('') String? unit,
  }) = _GroceryProduct;

  factory GroceryProduct.fromJson(Map<String, dynamic> json) =>
      _$GroceryProductFromJson(json);
}

@freezed
class GroceryProductDto with _$GroceryProductDto {
  const factory GroceryProductDto({
    @JsonKey(name: 'grocery_id') required String groceryId,
    @JsonKey(name: 'product_id') required String productId,
    @Default(1) int quantity,
    String? unit,
  }) = _GroceryProductDto;

  factory GroceryProductDto.fromJson(Map<String, dynamic> json) =>
      _$GroceryProductDtoFromJson(json);
}

@freezed
class AuthDto with _$AuthDto {
  const factory AuthDto({
    required String email,
    required String password,
    String? name,
  }) = _AuthDto;

  factory AuthDto.fromJson(Map<String, dynamic> json) =>
      _$AuthDtoFromJson(json);
}


Enter fullscreen mode Exit fullscreen mode

上面的代码将生成以下文件

我们不必编写所有内容,只需让它自动生成即可build_runner

为了让您了解我们的数据模型,我们看到我们有用于杂货应用程序的主要表。

  • 应用用户
  • 杂货店
  • 产品
  • 杂货产品

DTO

  • GroceryDto
  • 产品数据
  • 杂货产品Dto
  • 授权

但是那些带有“Dto”名称的数据模型是什么?

DTO 只是数据传输对象的意思,我喜欢在我发出的任何 API 请求中使用 DTO。

数据传输对象 (DTO) 是一种用于封装数据并将其从应用程序的一个子系统发送到另一个子系统的对象。N 层应用程序中的服务层最常使用 DTO 在其自身与 UI 层之间传输数据。

Flutter 设置

安装并设置 Flutter 应用。然后使用以下依赖项来设置 Supabase。

软件包:

我添加了postgrest,因为我想从包中获取所有类型,而 Supabase 正在使用它们。

完成后,您可以继续设置 Supabase 客户端



import 'package:supabase/supabase.dart';

// use your own SUPABASE_URL
const String SUPABASE_URL = 'https://borayzhhitkyveigfijz.supabase.co';

// use your own SUPABASE_SECRET key
const String SUPABASE_SECRET =
    'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYxOTMwODI5MCwiZXhwIjoxOTM0ODg0MjkwfQ.Kk1ckyjzCB98aWyBPtJsoWuTsbq2wyYfiUxG7fH4yAg';

final SupabaseClient supabase = SupabaseClient(SUPABASE_URL, SUPABASE_SECRET);


Enter fullscreen mode Exit fullscreen mode

这些可以在项目设置的 API 选项卡中找到。要获取 SUPABASE_URL

图像

SUPABASE_SECRET

图像

然后,当设置完成后,我们可以进行查询!

Supabase 查询

如果您了解 SQL 或熟悉它,那么感觉应该非常相似。

但这些将由 Supabase 自动生成,所以如果您不知道如何构建 Supabase 查询,也不用担心。只需检查项目 API,它会在您更新表或更改任何列时动态生成。

相比之下,这是一个 RAW SQL 查询。



SELECT * FROM products


Enter fullscreen mode Exit fullscreen mode

这就是在 Dart 中使用 Supabase 编写查询的方法



supabase.from("products").select().execute();


Enter fullscreen mode Exit fullscreen mode

确保execute最后一部分始终有,否则它将无法从products表中获取所有数据。

那么查询单个记录怎么样?

在 SQL 中,我们有



SELECT * FROM products WHERE id = "uuid-string";


Enter fullscreen mode Exit fullscreen mode

在 Supabase Dart 中,我们有



supabase.from("products").select().eq("id", "uuid-string").single().execute();


Enter fullscreen mode Exit fullscreen mode

您的 Supabase 项目中还有更多查询可供展示,因此请务必在此处查看

图像

验证

在每个应用程序中,保护用户数据安全的一种方法就是拥有一个身份验证系统。因此,使用 Supabase,您可以非常轻松地立即开始身份验证,因为他们提供了一个非常简单直观的 API!



class AuthenticationService {
  final _logger = Logger();
  final _localStorageService = locator<LocalStorageService>();

  AppUser? _user = null;
  AppUser? get user => _user;
  bool get hasUser => _user != null;

  Future<void> initialize() async {}

  Future<AppUser?> signIn({required AuthDto payload}) async {}

  Future<AppUser?> signUp({required AuthDto payload}) async {}

  Future<void> signOut() async {}

  Future<AppUser?> fetchUser({required String id}) async {}

  Future<PostgrestResponse> _createUser(User user, AuthDto payload) {}
}


Enter fullscreen mode Exit fullscreen mode

把上面的代码分解一下。这依赖于本地存储服务(Shared Preferences),我们将在其中存储 JWT 授权令牌/刷新令牌,以及用于调试的 Logger。所以我喜欢随身携带一个 Logger。

我们有一个私有属性,我们使用自己的 getter 和布尔 getter 来存储我们的用户,如果该属性不为空,_user则检查用户是否已登录。_user

在这个initialize()方法内部,我们将执行自动登录。因此,如果用户的本地存储中存储了刷新令牌,我们将继续登录该用户,获取用户数据并将其存储在_user属性中,这样hasUser布尔值 getter 的值就会为 true。



Future<void> initialize() async {
    final accessToken = await _localStorageService.getItem('token');
    _logger.i(accessToken);

    if (accessToken == null) {
      return;
    }

    final response = await supabase.auth.api.getUser(accessToken);

    if (response.error != null) {
      return;
    }

    final user = response.data!;
    _logger.i(user.toJson());
    await fetchUser(id: user.id);
  }


Enter fullscreen mode Exit fullscreen mode

接下来是signIn带有参数的方法,AuthDto该参数包含email一个password字段。当用户提供正确且现有的电子邮件时,我们将获取他们的访问令牌并将其存储在本地存储中。



Future<AppUser?> signIn({required AuthDto payload}) async {
    final response = await supabase.auth.signIn(
      email: payload.email,
      password: payload.password,
    );

    if (response.error != null) {
      _logger.e(response.error!.message);
      return null;
    }
    _logger.i(response.data);
    await _localStorageService.setItem('token', response.data!.accessToken);
    return await fetchUser(id: response.data!.user!.id);
  }


Enter fullscreen mode Exit fullscreen mode

signUp每当有新用户想要使用我们的应用时,我们都会使用该方法。创建新用户时,我们会获取访问令牌并将其保存到本地存储。我们还会在app_users表中创建新的用户记录,但会使用另一个名为_createUser



Future<AppUser?> signUp({required AuthDto payload}) async {
    final response =
        await supabase.auth.signUp(payload.email, payload.password);

    if (response.error != null) {
      _logger.e(response.error!.message);
      return null;
    }

    final user = response.data!.user!;
    _logger.i(user.toJson());
    await _createUser(user, payload);
    await _localStorageService.setItem('token', response.data!.accessToken);
    return await fetchUser(id: user.id);
  }


Enter fullscreen mode Exit fullscreen mode

_createdUser将在表中创建一个新的用户记录app_users



Future<PostgrestResponse> _createUser(User user, AuthDto payload) {
    return supabase
        .from("app_users")
        .insert(
          AppUser(
            id: user.id,
            name: payload.name!,
            email: user.email,
          ),
        )
        .execute();
  }


Enter fullscreen mode Exit fullscreen mode

然后,signOut这已经不言自明了。这里我们只是在用户决定删除访问令牌时,从本地存储中删除访问令牌。signOut



Future<void> signOut() async {
    final response = await supabase.auth.signOut();

    if (response.error != null) {
      _logger.e(response.error!.message);
      return;
    }
    _logger.i(response.rawData);
    await _localStorageService.removeItem('token');
    return;
  }


Enter fullscreen mode Exit fullscreen mode

最后fetchUser,我们有一种方法可以获取当前已验证的用户记录,以便我们随时可以在整个应用程序中获取他们的信息。



Future<AppUser?> fetchUser({required String id}) async {
    final response = await supabase
        .from("app_users")
        .select()
        .eq('id', id)
        .single()
        .execute();

    _logger.i(
      'Count: ${response.count}, Status: ${response.status}, Data: ${response.data}',
    );

    if (response.error != null) {
      _logger.e(response.error!.message);
      return null;
    }

    _logger.i(response.data);
    final data = AppUser.fromJson(response.data);
    _user = data;

    return data;
  }


Enter fullscreen mode Exit fullscreen mode

Supabase 服务

我们完成了数据模型和身份验证的处理,接下来就可以为应用程序创建和处理读写操作了。得益于抽象的概念,我们无需为相同的功能编写大量代码,只需编写更少的代码,即可将此功能扩展到其他需要它的服务。

以下是处理 CRUD 操作(Cread、Read、Update、Delete)的抽象类



import 'package:logger/logger.dart';
import 'package:postgrest/postgrest.dart';
import 'package:supagrocery/app/app.locator.dart';
import 'package:supagrocery/app/supabase_api.dart';
import 'package:supagrocery/services/authentication_service.dart';

abstract class SupabaseService<T> {
  final _authService = locator<AuthenticationService>();
  final _logger = Logger();

  String tableName() {
    return "";
  }

  Future<PostgrestResponse> all() async {
    _logger.i(tableName());
    final response = await supabase
        .from(tableName())
        .select()
        .eq('created_by', _authService.user!.id)
        .execute();
    _logger.i(response.toJson());
    return response;
  }

  Future<PostgrestResponse> find(String id) async {
    _logger.i(tableName() + ' ' + id);
    final response = await supabase
        .from(tableName())
        .select()
        .eq('id', id)
        .single()
        .execute();
    _logger.i(response.toJson());
    return response;
  }

  Future<PostgrestResponse> create(Map<String, dynamic> json) async {
    _logger.i(tableName() + ' ' + json.toString());
    final response = await supabase.from(tableName()).insert(json).execute();
    _logger.i(response.toJson());
    return response;
  }

  Future<PostgrestResponse> update({
    required String id,
    required Map<String, dynamic> json,
  }) async {
    _logger.i(tableName() + ' ' + json.toString());
    final response =
        await supabase.from(tableName()).update(json).eq('id', id).execute();
    _logger.i(response.toJson());
    return response;
  }

  Future<PostgrestResponse> delete(String id) async {
    _logger.i(tableName() + ' ' + id);
    final response =
        await supabase.from(tableName()).delete().eq('id', id).execute();
    _logger.i(response.toJson());
    return response;
  }
}


Enter fullscreen mode Exit fullscreen mode

这个抽象类依赖于AuthenticationService我们刚刚创建的,因此每次用户在我们的数据库中创建记录时,我们都可以附加用户的 ID。

我们将为tableName每个需要的功能服务重写 。因此,在创建ProductService和 时,我们只需使用相应的表名GroceryService扩展此类和该重写即可。tableName

这是一个例子ProductService



import 'package:postgrest/postgrest.dart';
import 'package:supagrocery/app/app.locator.dart';
import 'package:supagrocery/app/supabase_api.dart';
import 'package:supagrocery/datamodels/application_models.dart';
import 'package:supagrocery/services/authentication_service.dart';
import 'package:supagrocery/services/supabase_service.dart';

class ProductService extends SupabaseService<Product> {
  final _authService = locator<AuthenticationService>();

  @override
  String tableName() {
    return "products";
  }

  Future<PostgrestResponse> fetchProducts() async {
    return await supabase
        .from("products")
        .select("*")
        .eq('created_by', _authService.user!.id)
        .execute();
  }
}


Enter fullscreen mode Exit fullscreen mode

这还将包含SupabaseService我们创建的抽象类中的方法,并且无需重写任何内容,我们只需重写tableName并返回该表的名称即可。有了它,ProductService我们就可以编写任何与业务逻辑相关的方法了。

那么这是我们的GroceryService



import 'package:postgrest/postgrest.dart';
import 'package:supagrocery/app/app.locator.dart';
import 'package:supagrocery/app/supabase_api.dart';
import 'package:supagrocery/datamodels/application_models.dart';
import 'package:supagrocery/services/supabase_service.dart';

import 'authentication_service.dart';

class GroceryService extends SupabaseService<Grocery> {
final _authService = locator<AuthenticationService>();

@override
String tableName() {
return "groceries";
}

Future<PostgrestResponse> fetchGroceryList({required String id}) async {
return await supabase
.from("groceries")
.select(", grocery_products(, products(*) )")
.eq('id', id)
.eq('created_by', _authService.user!.id)
.single()
.execute();
}

Future<PostgrestResponse> addProductsToList({
required String id,
required List<Product?> products,
}) async {
return await supabase
.from("grocery_products")
.insert(
products.map((e) {
return GroceryProductDto(
groceryId: id,
productId: e!.id,
).toJson();
}).toList(),
)
.execute();
}

Future<PostgrestResponse> markProductChecked(
{required GroceryProduct payload}) async {
return await supabase
.from("grocery_products")
.update(payload.toJson())
.eq('id', payload.id)
.execute();
}

Future<PostgrestResponse> removeProduct({required String id}) async {
return await supabase
.from("grocery_products")
.delete()
.eq('id', id)
.execute();
}
}

Enter fullscreen mode Exit fullscreen mode




概括

我们介绍了数据库设计、Supabase 设置、使用 Supabase API 实现身份验证系统以及使用抽象轻松实现新功能。

我希望这能给你一个想法并且在某种程度上是有用的。

感谢您的阅读并希望您喜欢!

存储库链接

鏂囩珷鏉ユ簮锛�https://dev.to/carlomigueldy/building-a-simple-grocery-app-in-flutter-with-supabase-5fad
PREV
撰写架构决策记录
NEXT
通过构建 UI 框架学习 JavaScript:第 5 部分 - 向 Dom 元素添加事件