#83 Flutter 05-Riverpod 狀態管理,Provider 與 FutureProvider 實作-台灣熱門小吃食譜 App

在 Riverpod 中,除了 StateProvider,還有許多其他常用的提供者(Provider)類型,每個都有不同的用途和特性。前一篇我們使用了StateProvider 實作,這次我們要使用 ProviderFutureProvider 來抓取之前在 GitHub 上自定義的 JSON API,以建立台灣熱門小吃食譜 App。

前一篇及之前建立 GitHub JSON API 的文章:

APP操作GIF:

APP螢幕截圖:

Provider 簡介

Provider

Provider 用於提供不會改變的數據,例如配置信息、服務等。它在構建時提供一次值,並在後續重建中重用。示例:

final configProvider = Provider<Map<String, dynamic>>((ref) {
return {'apiBaseUrl': 'https://api.example.com'};
});

FutureProvider

FutureProvider 用於提供異步加載的數據,它會在異步操作完成後更新狀態。示例:

final snacksProvider = FutureProvider<List<Snack>>((ref) async {
final config = ref.watch(configProvider);
final response = await http.get(Uri.parse(config['apiBaseUrl']));

if (response.statusCode == 200) {
List<dynamic> data = json.decode(response.body);
return data.map((item) => Snack.fromJson(item)).toList();
} else {
throw Exception('Failed to load snacks');
}
});

之後在需要處理回傳內容的地方去監聽 FutureProvider

使用 when 處理不同狀態,whenAsyncValue 提供的一個方法,用於處理 FutureProvider 返回的數據。它能夠根據數據的不同狀態(加載中、成功或失敗)來顯示對應的 UI。

class SnackList extends ConsumerWidget {

@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncSnacks = ref.watch(snacksProvider);

return asyncSnacks.when(
data: (snacks) => ...
loading: () => ...,
error: (err, stack) => ...,
);
}
}

步驟 1:建立專案

建立一個新的 Flutter 專案並命名為 github_json_api

步驟 2:安裝 Riverpod

pubspec.yaml 文件中添加 flutter_riverpodhttp依賴:

dependencies:
flutter:
sdk: flutter

flutter_riverpod: ^2.5.1
http: ^1.2.1

然後在終端(terminal)中執行以下指令來安裝依賴:

//flutter pub cache clean //如果需要清除之前的安裝的
flutter pub get

步驟 3:定義 Providers

定義 Provider

我們使用 Provider 來定義 API 的 URL。這個 URL 指向一個包含小吃食譜的 JSON 文件。

final configProvider = Provider<Map<String, dynamic>>((ref) {
return {'apiBaseUrl': 'https://raw.githubusercontent.com/JasonHungApp/JSON_API/main/TaiwanSnackPreparation.json'};
});

定義 FutureProvider

使用 FutureProvider 來抓取 API 的內容,可以看到有 async / await 的搭配。

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';

// 定義 configProvider,提供靜態的 API 配置
final configProvider = Provider<Map<String, dynamic>>((ref) {
return {'apiBaseUrl': 'https://raw.githubusercontent.com/JasonHungApp/JSON_API/main/TaiwanSnackPreparation.json'};
});

// 定義 snacksProvider,使用 configProvider 來獲取 API URL
final snacksProvider = FutureProvider<List<Snack>>((ref) async {
final config = ref.watch(configProvider);
final response = await http.get(Uri.parse(config['apiBaseUrl']));

if (response.statusCode == 200) {
List<dynamic> data = json.decode(response.body);
return data.map((item) => Snack.fromJson(item)).toList();
} else {
throw Exception('Failed to load snacks');
}
});

步驟 4:定義 JSON 的格式

先透過 API 網址,查看 API 回傳的JSON格式

[
{
"name": "小籠包",
"type": "點心",
"ingredients": [
{"name": "豬肉", "quantity": "200克"},
{"name": "湯汁", "quantity": "100毫升"},
{"name": "餃子皮", "quantity": "適量"}
],
"instructions": [
"將豬肉剁成細末。",
"將湯汁和剁碎的豬肉混合均勻。",
"將適量的餃子皮包入混合物,形成小籠包的形狀。",
"蒸煮約15分鐘,直至小籠包熟透即可。"
],
"details": {
"description": "以豬肉餡料和特製湯汁為特色的小吃,包裹在薄薄的餃子皮中。",
"origin": "上海",
"popular_places": ["臺北市", "新北市"]
}
},
]

依回傳的 JSON格式,設定對應的 Class。

// 定義 Snack 模型類
class Snack {
final String name;
final String type;
final List<Ingredient> ingredients;
final List<String> instructions;
final SnackDetails details;

Snack({
required this.name,
required this.type,
required this.ingredients,
required this.instructions,
required this.details,
});

factory Snack.fromJson(Map<String, dynamic> json) {
return Snack(
name: json['name'],
type: json['type'],
ingredients: (json['ingredients'] as List)
.map((item) => Ingredient.fromJson(item))
.toList(),
instructions: List<String>.from(json['instructions']),
details: SnackDetails.fromJson(json['details']),
);
}
}

// 定義 Ingredient 模型類
class Ingredient {
final String name;
final String quantity;

Ingredient({required this.name, required this.quantity});

factory Ingredient.fromJson(Map<String, dynamic> json) {
return Ingredient(
name: json['name'],
quantity: json['quantity'],
);
}
}

// 定義 SnackDetails 模型類
class SnackDetails {
final String description;
final String origin;
final List<String> popularPlaces;

SnackDetails({
required this.description,
required this.origin,
required this.popularPlaces,
});

factory SnackDetails.fromJson(Map<String, dynamic> json) {
return SnackDetails(
description: json['description'],
origin: json['origin'],
popularPlaces: List<String>.from(json['popular_places']),
);
}
}

創建新的 Dart 文件

lib 目錄下創建一個新的檔案來放這些 class,比如 models/snack.dart

在需要引用的檔案加上 import

import 'package:github_json_api/models/snack.dart';

在主應用中使用 Providers

在主應用 MyApp 中,使用 ProviderScope 包裹根部件:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:github_json_api/models/snack.dart';

void main() {
runApp(ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('台灣熱門小吃食譜', style: TextStyle(fontSize: 26))),
body: SnackList(),
),
);
}
}

使用 ConsumerWidget 來讀取 Providers

使用 ConsumerWidget 來讀取 snacksProvider 的資料並顯示:

class SnackList extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncSnacks = ref.watch(snacksProvider);

return asyncSnacks.when(
data: (snacks) => ListView.builder(
itemCount: snacks.length,
itemBuilder: (context, index) {
final snack = snacks[index];
return ListTile(
title: Text(snack.name),
subtitle: Text(snack.details.description),
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(snack.name),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Type: ${snack.type}'),
Text('Ingredients:'),
...snack.ingredients
.map((ing) => Text('${ing.name}: ${ing.quantity}'))
.toList(),
Text('Instructions:'),
...snack.instructions.map((instr) => Text(instr)).toList(),
Text('Origin: ${snack.details.origin}'),
Text('Popular Places:'),
...snack.details.popularPlaces.map((place) => Text(place)).toList(),
],
),
),
);
},
);
},
),
loading: () => Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
);
}
}

備註

  • ProviderScope:提供所有 Providers 的範圍。
  • snacksProvider:使用 FutureProvider 獲取 JSON 資料。
  • SnackIngredientSnackDetails:定義 JSON 結構的模型類。
  • ConsumerWidget:用來讀取和顯示 Providers 的資料。
  • asyncSnacks.when:處理 FutureProvider 返回的異步資料,提供 dataloadingerror 三種狀態。

切分 showDialog 部分

onTap: () 裡,是顯示點擊列表後要出現的畫面,直接寫在裡面的話,看起來階層有點多,一堆括號,我們把它放到外面去。

定義一個函數來顯示 Snack 詳情的對話框,順便改一下排版,原本只是簡單的置中排列,改為靠左,子標題跟內容左側有4個空白的距離,改完後程式碼看起來又更複雜了。

void showSnackDetailsDialog(BuildContext context, Snack snack) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(snack.name),
content: SingleChildScrollView( // 使用 SingleChildScrollView 以防止內容過長溢出
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('類型:', style: TextStyle(fontWeight: FontWeight.bold)),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(snack.type),
),

const SizedBox(height: 10),
const Text('食材:', style: TextStyle(fontWeight: FontWeight.bold)),
...snack.ingredients.map((ing) => Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text('${ing.name}: ${ing.quantity}'),
)),

const SizedBox(height: 10),
const Text('作法:', style: TextStyle(fontWeight: FontWeight.bold)),
...snack.instructions.map((instr) => Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(instr),
)).toList(),

const SizedBox(height: 10),
const Text('發源地:', style: TextStyle(fontWeight: FontWeight.bold)),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(snack.details.origin),
),

const SizedBox(height: 10),
const Text('熱門地點:', style: TextStyle(fontWeight: FontWeight.bold)),
...snack.details.popularPlaces.map((place) => Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(place),
)).toList(),
],
),
),
),
);
}

預期看到的樣子

主頁面中的修改

SnackList 中修改 onTap,以調用新的函數,把一堆括號放到外面去,看起來有沒有精簡很多。

class SnackList extends ConsumerWidget {
const SnackList({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncSnacks = ref.watch(snacksProvider);

return asyncSnacks.when(
data: (snacks) => ListView.builder(
itemCount: snacks.length,
itemBuilder: (context, index) {
final snack = snacks[index];
return ListTile(
title: Text(snack.name),
subtitle: Text(snack.details.description),
onTap: () => showSnackDetailsDialog(context, snack), // 調用新的函數
);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
);
}
}

這樣就完成了一個簡單的台灣熱門小吃食譜 App,通過使用 Riverpod 管理狀態,並從 GitHub 上抓取 JSON 資料來展示小吃的詳細信息。

參考:

補充:

ListView

使用 ListView 來排版,

主要參數

  1. * itemCountitemCount 指定了列表項的數量,這裡我們設置為 snacks.length,即小吃列表的長度。
  2. itemBuilderitemBuilder 是一個回調函數,用於創建每個列表項。它有兩個參數:
  • context:構建上下文。
  • index:當前項目的索引。

itemBuilder 中,我們獲取當前索引的 snack,並返回一個 ListTile 小部件,ListTile 是一個方便的預構建小部件,用於創建標準列表項。

ListTile

  • title:顯示小吃的名稱,並設置字體大小和顏色。
  • subtitle:顯示小吃的描述,並設置顏色。
  • onTap:當用戶點擊列表項時,調用 showSnackDetailsDialog 函數來顯示小吃的詳細信息。

查看一下畫面

背景圖

好像素了點,再練習加個背景圖

在 Flutter 中,你可以使用 BoxDecorationDecorationImage 來為 SnackList 添加背景圖。

創建 assets 文件夾

  • 在 Android Studio 中,右鍵單擊你的專案根目錄,選擇 New > Directory。命名這個新文件夾為 assets

添加圖片到 assets 文件夾

  • 將你想要作為背景的圖片文件(例如 background.png)拷貝到 assets 文件夾中。

在pubspec.yaml 配置背景圖資源

首先,確保在 pubspec.yaml 中添加背景圖片資源:

flutter:
assets:
- assets/background.png

確保你的圖片 background.png 位於 assets 文件夾中。

修改 SnackList 來設置背景圖

接下來,修改 SnackList 小部件,將 Scaffoldbody 包裝在 Container 中,並設置背景圖片。

原本

class SnackList extends ConsumerWidget {
const SnackList({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncSnacks = ref.watch(snacksProvider);

return asyncSnacks.when(
...

加上 Container

class SnackList extends ConsumerWidget {
const SnackList({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncSnacks = ref.watch(snacksProvider);

return Container(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/background.png'),
fit: BoxFit.cover,
),
),

child: asyncSnacks.when(
...

備忘

Android Studio 程式碼自動排版的功能

--

--