--- tags: Flutter --- # Flutter 結合 provider 與 firebase 建立簡易電商 APP 使用 provider 套件管理狀態,並搭配 Firebase 資料庫 建構可登入註冊、新增商品、加入購物車、成立訂單的電商 APP ## 狀態管理 在小部件之間如有需要傳遞狀態時,通常會使用有狀態小部件利用傳參數的方式處理, 但在架構或應用複雜的情況下則容易讓程式碼變得非常龐大且難維護, 這時候推薦使用 `Provider` 第三方依賴, > Provider 說明文件:https://flutter.cn/docs/development/data-and-backend/state-mgmt/simple 可通過終端機執行 `flutter pub add provider` 進行安裝 主要狀態管理邏輯: * 由 `flutter` 提供了一個 `class` 為 `ChangeNotifier` ,用於向監聽器發送通知,通過呼叫 `notifyListeners()` 來通知大家狀態改變了 * 由 `provider` 提供了一個小部件為 `ChangeNotifierProvider` 用來向其子孫小部件傳遞狀態變更的通知,可以當成是一個部件之間傳遞數據變更通知的橋樑,需放在要訪問狀態的小部件父層 * 在擁有 `ChangeNotifierProvider` 的子孫部件中可以通過 `Consumer()` 或 `Provider.of` 來獲取並使用數據對象 舉例來說: 假設在電商 APP 中,可以建立一個 Products 專用的 `ChangeNotifier`, 並將與 Products 相關的所有函數或參數建立於此, 首先建立一個 `products.dart` 檔案, 通過 `with` 關鍵字建立 `ChangeNotifier` 的 `class`,並新增需要的相關數據與方法: ```dart= class Products with ChangeNotifier { List<Product> _items = []; // 主要數據內容 List<Product> get items { // 建立一個拷貝版的數據,於操作時才不會影響到主要數據 return [..._items]; } Product findById(String prodId) { // 獲取特定商品的方法 return items.firstWhere((element) => element.id == prodId); } } ``` 而在商品細節頁,我們需要獲取特定的商品數據以做顯示, 所以可以新增一個 `product_details.dart` 檔案, 並在裡面引入 `package:provider/provider.dart` 與 `products.dart` 檔案, 於 `Widget build` 中即可通過 `Provider.of<Products>(context)` 調用 `findById` 方法: ```dart= import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/products.dart'; class ProductDetailScreen extends StatefulWidget { static String routeName = '/product-detail'; @override State<ProductDetailScreen> createState() => _ProductDetailScreenState(); } class _ProductDetailScreenState extends State<ProductDetailScreen> { @override Widget build(BuildContext context) { final prodId = ModalRoute.of(context).settings.arguments as String; final product = Provider.of<Products>(context).findById(prodId); // 調用 Products 中的方法 return Scaffold( appBar: AppBar( title: Text(product.name), ), body: Container( child: Image.network( product.imgUrl, fit: BoxFit.contain, alignment: Alignment.topCenter, ), Text('NT\$ ${product.price.toString()}'), Text('${product.description}'), ), ) } } ``` > 需在 `Widget build` 中調用方法或參數是因為 `Provider.of` 需要傳入 `content` 上下文 > 另外在使用 `Provider.of` 時需先設置 `<>` 寫入要被使用的 `ChangeNotifier` 名稱,再接`(context)` 而在 `product_details.dart` 小部件的父層則須設置 `ChangeNotifierProvider`, 如果該 `product_details.dart` 小部件是一個路由畫面, 則應該於 `main.dart` 中設置 `ChangeNotifierProvider`: 1. 假設只建立一個 Provider 數據對象:在 `main.dart` 中使用 `ChangeNotifierProvider` `create`,其子孫小部件就可以獲取 Products 數據對象: ```dart= class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return ChangeNotifierProvider( create: (context) => Products(), // 告知要建立通知的數據對象 child: // MaterialApp... ) } } ``` 2. 假設同時要建立多個 Provider 數據對象:在 `main.dart` 中使用 `MultiProvider` 結合 `providers`,傳入多組 `ChangeNotifierProvider`: ```dart= class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MultiProvider( providers: [ ChangeNotifierProvider( create: (context) => Products(), // 建立 Products 的 Provider 數據對象 ), ChangeNotifierProvider( create: (context) => Cart(), // 建立 Cart 的 Provider 數據對象 ), ChangeNotifierProvider( create: (context) => Orders(), // 建立 Orders 的 Provider 數據對象 ), ], child: // MaterialApp... ) } } ``` 3. 假設需要從 A Provider 使用 B Provider 中的數據對象,則可以在 A Porvider 中建立一個變量存取數據對象,並將 `ChangeNotifierProvider` 改寫為: ```dart= class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MultiProvider( providers: [ ChangeNotifierProvider( // 先建立 B Provider create: (ctx) => Auth(), ), ChangeNotifierProxyProvider<Auth, Products>( // 再用 ChangeNotifierProxyProvider<B, A> 建立 A Provider create: (ctx) => Products(null, null, []), // 設置要存取的數據對象的預設值為空 update: (ctx, auth, previous) => Products(auth.token, auth.userId, previous == null ? [] : previous.items), // 設置要存取的數據對象的值為 B Provider 的數據對象 ), ], child: // MaterialApp... ) } } ``` 另外除了 `Provider.of` 以外,也可以通過 `Consumer` 來獲取 `Provider` 的參數或調用方法, 兩者的差別在於 `Provider.of` 會進行整個畫面的重構, 而如果是 `Consumer` 則只有被包裹在 `Consumer` 中的內容會進行重構 ```dart= Consumer<Cart>( // 獲取 Cart 的 Provider builder: (context, cart, myChild) => MyBadge( child: myChild, value: cart.itemCount.toString(), // 獲取 Cart 中的 itemCount 參數 ), child: IconButton( // 此處為 MyBadge 中不需被重構的小部件 icon: const Icon(Icons.shopping_bag), onPressed: () { Navigator.of(context).pushNamed( CartScreen.routeName, ); }, ), ) ``` ## Firebase 應用 可直接使用 Google 帳號進行註冊與登入 建立專案後選用 Realtime Database 建立資料庫(規則的部分選 test mode) 完成後會得到一個連結: https://xxxxxxxxxx.firebasedatabase.app ### rules 設定 這邊要先確認資料庫的規則部分,將其設為可以無條件寫入與讀取資料的設定: ``` { "rules": { ".read": true, ".write": true, } } ``` ### API 設計 以簡單的電商 APP 為例,整體架構至少會有商品與訂單的資料需要保存與抓取 商品的資料可以使用此 API: `https://xxxxxxxxxx.firebasedatabase.app/products.json` 訂單的資料則可以用此 API: `https://xxxxxxxxxx.firebasedatabase.app/orders.json` 在 firebase 中需通過 `json` 格式交互資料, 如發送 `POST`/`PUT`/`PATCH` 類型的寫入請求,則所有 body 的格式必須都是 `json` 發送請求回傳的 `response.body` 格式也都會是 `json` ### 利用第三方依賴發送 HTTP Request 首先需安裝第三方依賴 `http` 可於專案目錄中通過終端機輸入指令 `flutter pub add http` 進行安裝([發送請求的參考說明](https://docs.flutter.dev/cookbook/networking/fetch-data)) 接著在 Products 的 Provider 中撰寫商品相關的方法: 1. 引入 http 第三方依賴:`import 'package:http/http.dart' as http;` 2. 聲明一個 Future 類型的方法,其類型會返回 .then() 的回調 promise 3. 使用 Uri.https() 設置好 http request 的 URL,其傳入的三個參數分別是:網域、路徑、?後參數 4. 處理 response ,回傳的 response 為 json 格式,可通過引入 `import 'dart:convert';` 使用 `json.decode()` 將傳入的 `res.body` 轉換為需要的類型(通常會轉為 Map 對象);在發送 POST 請求時,也可使用 `json.encode()` 將傳入的內容轉換為 `json` 格式後設為 POST 請求的 `body` 5. 處理錯誤,因為 http 第三方套件,只會針對 GET 與 POST 請求拋出錯誤,所以在 DELETE/PUT/PATCH 請求中,需通過 `res.statusCode` 處理錯誤 以商品為例: - GET 請求:getProducts - 獲取所有在資料庫中的商品 ```dart= Future<void> getProducts() async { Uri url = Uri.https(firebaseUrl, '/products.json'); // https://xxxxx.firebasedatabase.app/products.json try { final res = await http.get(url); // 發送 GET 請求,傳入 url final data = json.decode(res.body) as Map<String, dynamic>; // 將 response 轉為 Object 類型 final List<Product> arr = []; // 建立一個空陣列,以將所有商品的 data 存入 if (data == null) { // 沒有資料則取消動作 return; } data.forEach((prodId, prodData) { // 解析資料,prodId 為 key,prodData 為 value arr.insert( // 從 index 0 處插入 0, Product( // Product class id: prodId, imgUrl: prodData['imgUrl'], description: prodData['description'], name: prodData['name'], price: prodData['price'], isFavorite: prodData['isFavorite'] == null ? false : prodData['isFavorite'], // 因 isFavorite 非必填項目,如果資料庫中抓取不到該資料則給個初始值 ), ); }); _items = arr; // 將 _items 設為利用 data 建立的新陣列 notifyListeners(); // 觸發監聽器通知畫面更新 } catch (err) { throw err; } } ``` - POST 請求: addProduct - 新增一個商品 ```dart Future<void> addProduct(Product prod) { Uri url = Uri.https(firebaseUrl, '/products.json'); return http .post(url, // 發送 POST 請求 body: json.encode({ // 傳入 json 格式的商品資訊 "name": prod.name, "description": prod.description, "price": prod.price, "imgUrl": prod.imgUrl, "isFavorite": false, }) ) .then((value) { final newProd = Product( // 建立一個 Product class name: prod.name, description: prod.description, price: prod.price, imgUrl: prod.imgUrl, id: json.decode(value.body)['name'], // 將 firebase 產生的隨機 key 設為商品 id isFavorite: false, ); _items.insert(0, newProd); // 從 index 0 處插入建立好的 Product notifyListeners(); // 觸發監聽器通知畫面更新 }).catchError((err) { throw err; }); } ``` - PATCH 請求: updateProduct 更新一個商品 ```dart Future<void> updateProduct(Product prod) async { final i = _items.indexWhere((element) => element.id == prod.id); // get index if (i != -1) { // 判斷 index 是否存在於 _items 中 Uri url = Uri.https(firebaseUrl, '/products/${prod.id}.json'); // 設置 http request URL,這次通過 productId 指定 products 中的特定一個對象進行操作 try { await http.patch(url, // 發送 PATCH 請求 body: json.encode({ // 傳入需要被更新的內容,不需更新的內容則不需傳入 "name": prod.name, "description": prod.description, "price": prod.price, "imgUrl": prod.imgUrl, }) ); _items[i] = prod; // 將原本的 Product 更新 notifyListeners(); // 觸發監聽器通知畫面更新 } catch (err) { throw err; } } } ``` - DELETE 請求: removeProduct 刪除一個商品 ```dart Future<void> removeProduct(String id) async { Uri url = Uri.https(firebaseUrl, '/products/${id}'); // 設置 http request URL,這次通過 productId 指定 products 中的特定一個對象進行操作 int i = _items.indexWhere((element) => element.id == id); // 獲取 index Product prod = _items[i]; // 獲取要被刪除的 Product 對象,用於在請求失敗時復原商品 _items.removeWhere((element) => element.id == id); // 先從資料中刪除 Product notifyListeners(); // 觸發監聽器通知畫面更新 final res = await http.delete(url); // 發送 delete 請求 if (res.statusCode >= 400) { // 這邊因為 http 第三方套件,只會針對 get 與 post 請求拋出錯誤,所以通過 res.statusCode 自行處理剩下請求的錯誤 _items.insert(i, prod); // 因刪除請求失敗,所以這邊將刪除的商品重新放回資料中 notifyListeners(); // 再次觸發監聽器通知畫面更新 throw HttpException('無法刪除商品'); // 拋出錯誤告知原因 } prod = null; // 完成後將 Product 對象清空 } ``` ### HTTP Request 處理錯誤的幾種方法 1. 使用 `.then()` 搭配 `.catchError()` 傳入回調函數拋出錯誤 ```dart= Future<void> getData() { Uri url = ...; return http .get(url) .then((res) { _data = json.decode(res.body) as Map<String, dynamic>; notifyListeners(); }) .catchError((err) { throw err; }); } Provider.of<Products>(context, listen: false) .getData() .catchError((err) { // 使用 catchError 方法 return showDialog<Null>( // 回傳 showDialog 小部件顯示 AlertDialog 彈窗小部件 context: context, builder: (ctx) => AlertDialog( title: Text('錯誤'), content: Text('執行操作時發生錯誤,請重試。'), actions: [ TextButton( child: Text('好的'), onPressed: () => Navigator.of(ctx).pop(), ), ], ), ); }) ``` 2. 使用自定義的 if/else 判斷條件搭配 `Exception` 拋出自定義異常 ```dart= // 通過 implements 關鍵字復用 Exception class class HttpException implements Exception { final String msg; HttpException(this.msg); @override String toString() { return msg; } } Future<void> getData() async { final Uri url = ...; try { final res = await http.get(url); if (json.decode(res.body)['error'] != null) { throw HttpException(json.decode(res.body)['error']['message']); // 拋出自定義異常 } _data = json.decode(res.body) as Map<String, dynamic>; notifyListeners(); } catch (err) { throw err; } } Future<void> _getData() async { try { await Provider.of<Products>(context, listen: false).getData(); } on HttpException catch (err) { // 如果有拋出自定義錯誤 showDialog<Null>( // 回傳 showDialog 小部件顯示 AlertDialog 彈窗小部件 context: context, builder: (ctx) => AlertDialog( title: const Text('錯誤'), content: Text('$err'), actions: [ TextButton( child: const Text('好的'), onPressed: () => Navigator.of(ctx).pop(), ), ], ), ); } catch (err) { showDialog<Null>( // 回傳 showDialog 小部件顯示 AlertDialog 彈窗小部件 context: context, builder: (ctx) => AlertDialog( title: const Text('錯誤'), content: Text('$err'), actions: [ TextButton( child: const Text('好的'), onPressed: () => Navigator.of(ctx).pop(), ), ], ), ); } } ``` ### 前台畫面的 HTTP Request 錯誤處理 1. 直接在小部件中通過 `FutureBuilder` 於發送請求的同時一併處理讀取與錯誤 ```dart= Widget build(BuildContext context) { return Scaffold( appBar: ..., body: FutureBuilder( // 使用 FutureBuilder 方法 future: Provider.of<Orders>(context, listen: false).getOrders(), // 設置 future 對象,這邊 getOrders 的類型必須是 Future<void>, listen 一定要為 false 否則會無限讀取 builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { // 判斷是否正在等待請求中 return LinearProgressIndicator(); // 顯示 Loading 用的進度條小部件 } else { if (snapshot.error != null) { // 假設有出現錯誤 return Center( child: Text('Something wrong...'), // 顯示自訂義錯誤內容 ); } else { return Consumer<Orders>( // FutureBuilder 必須搭配使用 Consumer 獲取資料來使用 builder: (context, ordersData, child) { return ListView.builder( itemBuilder: (context, index) { return OrderItem(ordersData.orders[index]); }, itemCount: ordersData.orders.length, ); }, ); } } }, ), ); } ``` 2. 在使用函數時,通過 async/await 搭配 try/catch 回傳 `AlertDialog` 或 `ScaffoldMessenger` 小部件 ```dart= // AlertDialog ElevatedButton( child: const Text('結帳'), onPressed: () async { try { await Provider.of<Orders>(context, listen: false).createOrder(cart.items.values.toList(), cart.totalAmount); ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: const Text('已成立訂單'), )); cart.clearItems(); Navigator.of(context).pushNamed(OrdersScreen.routeName); } catch (err) { return showDialog<Null>( context: context, builder: (ctx) => AlertDialog( title: const Text('錯誤'), content: const Text( '執行操作時發生錯誤,請重試。', style: TextStyle(color: Colors.black54), ), actions: [ TextButton( child: const Text('好的'), onPressed: () => Navigator.of(ctx).pop(), ), ], ), ); } }, ), // ScaffoldMessenger IconButton( onPressed: () async { final scaffold = ScaffoldMessenger.of(context); try { await Provider.of<Products>(context, listen: false) .removeProduct(id); } catch (err) { scaffold.showSnackBar(SnackBar( content: const Text('刪除失敗'), )); } }, icon: Icon(Icons.delete), color: Theme.of(context).colorScheme.error, ), ``` ### 使用 Firebase 的 Auth 功能 在 Firebase 專案中,找到 Authentication 點擊『設定登入方式』,會跳轉至『Sign-in method』,這邊點選『電子郵件/密碼』,設為啟用後儲存 接著回到 flutter 專案中,新增一個 `auth.dart` 檔案 用於建立 Auth 使用的 provider,並在裡面撰寫登入與註冊用的 HTTP Request([API 參考文檔](https://firebase.google.com/docs/reference/rest/auth)) 發送登入與註冊用的 HTTP Request 後即可以在 `res.body` 中獲取 idToken(token)、localId(userId)、expiresIn(token 會在幾秒後過期) 接著在 Firebase 專案中,重新設定 Realtime Database 的規則,將其改為: ``` { "rules": { ".read": "auth != null", ".write": "auth != null" } } ``` 這樣在對資料庫做讀取與寫入時的規則就會變為『只有經過身份驗證的用戶才能訪問數據』 此時需要為每個 HTTP Request 的 URL 加上 auth 驗證,否則將無法進行讀寫數據, 在驗證這部分,有別於大多數的資料庫需通過 `header` 傳入 `Authorization: Bearer {access_token}` , 在 firebase 中,可以簡單的通過於網址 ? 後帶參數的方式發送 token 進行身份驗證, (EX: https://xxxxxx.firebasedatabase.app/products.json?auth=token) 轉換為 Uri 的 URL 寫法則如下: ```dart Uri.https(firebaseUrl, '/products.json', {'auth': authToken}); ``` 當我們在 `auth.dart` 中通過登入與註冊 HTTP Request ,獲取到 `idToken`、`localId`、`expiresIn` 後,需要將 `idToken` 傳遞給所有需要發送請求的函數, 此時就屬於 Provider 之間傳遞數據對象的狀況,需使用 `ChangeNotifierProxyProvider` 來建立 Provider 並傳遞其他 Provider 的參數: ```dart= MultiProvider( providers: [ ChangeNotifierProvider( create: (ctx) => Auth(), ), ChangeNotifierProxyProvider<Auth, Products>( create: (ctx) => Products(null, null, []), update: (ctx, auth, previous) => Products( auth.token, auth.userId, previous == null ? [] : previous.items), ), ], child: ..., ) ``` > 以上方例子而言,需要在 Products 中取得 Auth 的 `token` 數據對象,所以在建立 Products 的 Provider 之前需要先建立好 Auth 的 Provider > 因此建立的順序應該是先用 `ChangeNotifierProvider` 建立 Auth ,接著再用 `ChangeNotifierProxyProvider<Auth, Products>` 建立 Products > 在 Products 中, `create` 時,傳入每個參數的初始值, `update` 時,從 從 auth 中獲取需要的數據對象傳入每個參數中: > `update: (ctx, auth, previousProducts) => Products(auth.token, auth.userId, previousProducts.items)` ### 自動登出 當我們發送登入請求後,可以得到 `expiresIn` 即 `token` 會在幾秒後過期, 有了 `expiresIn` 後就可以通過計時器計算過期時間,時間到時就執行登出的動作, 1. 引入 `import 'dart:async';` 以使用 `Timer()` 2. 設置 `logout` 函數,將登入時獲取到的 `token`/`userId`/`expiresIn` 都設為 `null` 3. 建立 `_autoLogout` 函數,調用 `Timer()` 方法,執行 `logout` 函數進行自動登出 4. 在發送登入請求成功後調用 `_autoLogout` 函數,開始跑計時登出 5. 將 `Timer()` 設置為一個參數,當主動進行登出時,就將計時器取消,以避免自動登出與主動登出產生重複 重點程式碼如下: ```dart= Timer _authTimer; // 設置一個存放計時器的變數 Future<void> login(String mail, String pwd) async { final Uri url = Uri.https('identitytoolkit.googleapis.com', {'key': 'Firebase Web API key here'}); try { final res = await http.post(url, body: json.encode({ "email": mail, "password": pwd, "returnSecureToken": true, }) ); if (json.decode(res.body)['error'] != null) { // 處理錯誤 throw HttpException(json.decode(res.body)['error']['message']); } // 保存回傳的重要數據 _token = json.decode(res.body)['idToken']; _expiryDate = DateTime.now().add(Duration(seconds: int.parse(json.decode(res.body)['expiresIn']))); _userId = json.decode(res.body)['localId']; _autoLogout(); // 開始執行自動登出 notifyListeners(); } catch (err) { throw err; } } void logout() { // 登出函數 _token = null; _userId = null; _expiryDate = null; _authTimer = null; notifyListeners(); } void _autoLogout() { // 自動登出函數 if (_authTimer != null) { // 如果計時器已存在就取消以重置計時器 _authTimer.cancel(); } int expirySeconds = _expiryDate.difference(DateTime.now()).inSeconds; // 獲取當前時間與過期時間的總秒數 _authTimer = Timer(Duration(seconds: expirySeconds), logout); // 設置計時器,時間到時執行登出函數 } ``` ### 自動登入 在 `flutter` 中,有個第三方依賴 `shared_preferences` 可以用來將資料存儲到設備中 我們可以通過 `shared_preferences` ,將登入後獲取到的資料保存到手機中, 當用戶關閉 APP 再重新打開食,撈取設備中保存的資料進行處理,即可自動登入帳號 1. 安裝並引入 shared_preferences ([點此前往 flutter.dev](https://pub.dev/packages/shared_preferences/install)) 2. 通過 `final prefs = await SharedPreferences.getInstance();` 獲取 Shared Preferences 3. 通過 `prefs.setString(key, value);` 存入特定資料 4. 通過 `prefs.getString(key)` 取出特定資料以做使用 重點程式碼如下: ```dart= import 'package:shared_preferences/shared_preferences.dart'; // 引入 SharedPreferences class Auth with ChangeNotifier { String _token; DateTime _expiryDate; String _userId; Timer _authTimer; Future<void> _authenticate(String mail, String pwd, String urlPath) async { final Uri url = Uri.https( 'identitytoolkit.googleapis.com', urlPath, {'key': 'Web API key here'}, ); try { final res = await http.post( url, body: json.encode({ "email": mail, "password": pwd, "returnSecureToken": true, }), ); _token = json.decode(res.body)['idToken']; _expiryDate = DateTime.now().add(Duration( seconds: int.parse(json.decode(res.body)['expiresIn']), )); _userId = json.decode(res.body)['localId']; notifyListeners(); final prefs = await SharedPreferences.getInstance(); // 獲取 Shared Preferences final userData = json.encode({ // 建立 json 格式的數據保存 userData 資料 "token": _token, "userId": _userId, "expiryDate": _expiryDate.toIso8601String(), }); prefs.setString("userData", userData); // 將 userData 存到 Shared Preferences 中 } catch (err) { throw err; } } Future<void> tryAutoLogin() async { // 建立一個 Future 類型的函數回傳布林值判斷是否有自動登入 final prefs = await SharedPreferences.getInstance(); // 獲取 Shared Preferences if (!prefs.containsKey("userData")) { // 判斷是否有 userData 的 key return false; } final data = json.decode(prefs.getString("userData")) as Map<String, Object>; // 將 userData 轉換為 Map final expiryDate = DateTime.parse(data['expiryDate']); // 獲取過期時間 if (expiryDate.isBefore(DateTime.now())) { // 判斷過期時間是否比現在時間更早 return false; } // 重新保存登入數據 _token = data['token']; _expiryDate = expiryDate; _userId = data['userId']; notifyListeners(); // 執行自動登出函數 _autoLogout(); return true; // 回傳 ture 告知已成功自動登入 } // 自動登出函數 Future<void> logout() async { _token = null; _userId = null; _expiryDate = null; _authTimer = null; notifyListeners(); final prefs = await SharedPreferences.getInstance(); // 獲取 Shared Preferences prefs.remove("userData"); // 從 Shared Preferences 中移除 userData // prefs.clear(); // 從 Shared Preferences 中清空所有資料 } void _autoLogout() { if (_authTimer != null) { _authTimer.cancel(); } int expirySeconds = _expiryDate.difference(DateTime.now()).inSeconds; _authTimer = Timer(Duration(seconds: expirySeconds), logout); } } ``` ### 在 flutter 中建立 `.env` 檔案(`dotenv` 套件) 可利用 `dotenv` 套件新增 `.env` 檔案存放 **web API key** & **firebase URL** 等敏感資訊 使用說明: https://www.geeksforgeeks.org/how-to-add-env-file-in-flutter/ (謝謝估狗大神)