使用gitpod開發flutter - 食譜app



成果畫面呈現




https://www.gitpod.io/docs/enterprise/introduction/getting-started/quickstart/flutter

https://github.com/gitpod-samples/template-flutter

點Open in Gitpod

新增screens folder建立home.dart

import 'package:flutter/material.dart';

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Home')),
      body: Center(
        child: Text(
          'Hello World!',
          style: TextStyle(fontSize: 24),
        ),
      ),

    );
  }
}

main.dart加入home page

import 'package:flutter/material.dart';
import 'package:gitpod_flutter_quickstart/screens/home.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSwatch(primarySwatch: Colors.blue).copyWith(secondary: Colors.black),
      ),
      home: HomePage(),
    );
  }
}


執行看看



新增utils folder建立class.dart

class Nutrients {
  String name;
  String weight;
  double percent;
  Nutrients({required this.name, required this.weight, required this.percent});
}

class Recipe {
  String id, imageUrl, title;
  List<String> steps;
  List<String> ingredients;
  List<Nutrients> nutrients;
  Recipe(
    {
      required this.id,
      required this.title,
      required this.imageUrl,
      required this.steps,
      required this.ingredients,
      required this.nutrients
    }
  );
}

建立data.dart

import 'package:gitpod_flutter_quickstart/utils/class.dart';

class Data {
  static List<Recipe> recipes = [
    Recipe(
        id: '1',
        title: '水餃',
        imageUrl:
            'https://images.unsplash.com/photo-1496116218417-1a781b1c416c?ixlib=rb-1.2.1&auto=format&fit=crop&w=750&q=80',
        nutrients: [
          Nutrients(name: '卡路里', weight: '200', percent: 0.7),
          Nutrients(name: '蛋白質', weight: '10gm', percent: 0.5),
          Nutrients(name: '碳水化合物', weight: '50gm', percent: 0.9),
        ],
        steps: [
          '準備餡料:將絞肉、切碎的蔬菜、醬油和調味料混合均勻。',
          '在餃子皮中央放一勺餡料,對折並捏緊封口。',
          '選擇煮法:水煮、蒸或煎至熟透。'
              '搭配醬料,趁熱享用。'
        ],
        ingredients: [
          '1/2 磅絞肉(豬肉或雞肉)',
          '2 包水餃皮',
          '切碎的高麗菜',
          '2 湯匙 醬油'
        ]),
    Recipe(
      id: '2',
      title: '卡布奇諾',
      imageUrl:
          'https://images.unsplash.com/photo-1444418185997-1145401101e0?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1391&q=80',
      nutrients: [
        Nutrients(name: '熱量', weight: '200 大卡', percent: 0.7),
        Nutrients(name: '蛋白質', weight: '10 克', percent: 0.5),
        Nutrients(name: '碳水化合物', weight: '50 克', percent: 0.9),
      ],
      steps: [
        '準備好所有食材。',
        '將兩杯濃縮咖啡倒入卡布奇諾杯中。',
        '打發牛奶,使其體積增加一倍。',
        '將打發好的牛奶倒入濃縮咖啡中,形成均勻比例的泡沫、蒸汽牛奶和濃縮咖啡。',
        '立即享用。'
      ],
      ingredients: ['2 份 濃縮咖啡(雙倍濃縮)', '4 盎司 牛奶'],
    ),
    Recipe(
      id: '3',
      title: '義大利麵',
      imageUrl:
          'https://images.unsplash.com/photo-1473093295043-cdd812d0e601?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=750&q=80',
      nutrients: [
        Nutrients(name: '熱量', weight: '100 大卡', percent: 0.2),
        Nutrients(name: '蛋白質', weight: '10 克', percent: 0.7),
        Nutrients(name: '碳水化合物', weight: '50 克', percent: 0.6),
        Nutrients(name: '脂肪', weight: '10 克', percent: 0.3),
      ],
      steps: [
        '準備好所有食材。',
        '將水煮沸並加入適量鹽,然後加入義大利麵,煮至軟硬適中。',
        '在平底鍋中加熱橄欖油,炒香大蒜和洋蔥。',
        '加入番茄醬,慢燉 10 分鐘,調味。',
        '瀝乾義大利麵,與醬汁混合均勻。',
        '撒上起司或羅勒葉後即可享用。'
      ],
      ingredients: [
        '200 克 義大利麵',
        '2 湯匙 橄欖油',
        '1 顆 洋蔥(切碎)',
        '2 瓣 大蒜(切碎)',
        '1 杯 番茄醬',
        '適量 鹽和胡椒',
        '適量 起司或羅勒葉(可選)'
      ],
    ),
    Recipe(
      id: '4',
      title: '披薩',
      imageUrl:
          'https://images.unsplash.com/photo-1506354666786-959d6d497f1a?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=750&q=80',
      nutrients: [
        Nutrients(name: '熱量', weight: '200 大卡', percent: 0.7),
        Nutrients(name: '蛋白質', weight: '10 克', percent: 0.5),
        Nutrients(name: '碳水化合物', weight: '50 克', percent: 0.9),
      ],
      steps: [
        '準備好所有食材。',
        '將麵粉、酵母、水和鹽混合,揉成麵團,靜置發酵 1 小時。',
        '將麵團擀成圓形,放在烤盤上。',
        '在麵團上塗抹番茄醬,撒上起司和配料。',
        '將披薩放入 220°C 預熱的烤箱,烘烤 15-20 分鐘。',
        '取出披薩,切片後即可享用。'
      ],
      ingredients: [
        '2 杯 麵粉',
        '1 湯匙 酵母',
        '1/2 杯 水',
        '1 茶匙 鹽',
        '1/2 杯 番茄醬',
        '1 杯 起司',
        '適量 喜愛的配料(如蘑菇、臘腸、青椒)'
      ],
    ),
  ];
}

建立widget.dart

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:gitpod_flutter_quickstart/utils/class.dart';

// CircleIndicator 是一個 StatefulWidget,用來顯示圓形進度條並且加上動畫效果
class CircleIndicator extends StatefulWidget {
  final double percent; // 圓形進度條的填充百分比 (範圍是 0.0 到 1.0)
  final Nutrients nutrient; // 用來顯示營養素的物件,包含名稱和重量

  // 構造函數,預設 percent 是 0.5
  CircleIndicator({this.percent = 0.5, required this.nutrient});

  @override
  _CircleIndicatorState createState() => _CircleIndicatorState();
}

// CircleIndicator 的狀態管理類別
class _CircleIndicatorState extends State<CircleIndicator>
    with SingleTickerProviderStateMixin {

  double fraction = 0.0; // 用來保存當前的動畫進度值
  late Animation<double> animation; // 動畫控制器,用來管理進度動畫

  @override
  void initState() {
    super.initState();
    // 初始化動畫控制器,設置動畫持續時間為 1000 毫秒
    var controller = AnimationController(
        duration: Duration(milliseconds: 1000), vsync: this);

    // 設置動畫,從 0.0 開始到 widget.percent 的結束值
    animation = Tween(begin: 0.0, end: widget.percent).animate(controller)
      ..addListener(() {
        setState(() {
          fraction = animation.value; // 隨著動畫進度更新 fraction
        });
      });

    controller.forward(); // 啟動動畫
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Stack(
        children: <Widget>[
          // 顯示營養素的名稱和重量
          Container(
            width: 70,
            height: 70,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                Text(
                  widget.nutrient.name, // 顯示營養素的名稱
                  style: TextStyle(color: Colors.white),
                ),
                Text(
                  widget.nutrient.weight, // 顯示營養素的重量
                  style: TextStyle(color: Colors.white),
                ),
              ],
            ),
          ),
          // 圓形進度條的外層容器,使用 CustomPaint 來繪製圓形
          Container(
            width: 70,
            height: 70,
            child: CustomPaint(
              foregroundPainter: CirclePainter(fraction), // 傳入當前進度值來繪製圓形
            ),
          ),
        ],
      ),
    );
  }
}

// CirclePainter 用來繪製圓形進度條
class CirclePainter extends CustomPainter {
  late Paint _paint; // 用來繪製圓形的畫筆
  double _fraction; // 當前的進度值

  CirclePainter(this._fraction) {
    // 初始化畫筆
    _paint = Paint()
      ..color = Color(0xff8DB646) // 設置圓形顏色
      ..strokeWidth = 5.0 // 設置圓形邊框寬度
      ..strokeCap = StrokeCap.round // 設置圓角
      ..style = PaintingStyle.stroke; // 只繪製邊框
  }

  @override
  void paint(Canvas canvas, Size size) {
    var rect = Offset(0.0, 0.0) & size; // 設定圓形範圍
    // 使用畫筆繪製圓形,根據 _fraction 設置繪製的角度
    canvas.drawArc(rect, -pi / 2, pi * 2 * _fraction, false, _paint);
  }

  @override
  bool shouldRepaint(CirclePainter oldDelegate) {
    return oldDelegate._fraction != _fraction; // 如果進度值有變化,則需要重繪
  }
}

建立widget.dart

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:gitpod_flutter_quickstart/utils/class.dart';

// CircleIndicator 是一個 StatefulWidget,用來顯示圓形進度條並且加上動畫效果
class CircleIndicator extends StatefulWidget {
  final double percent; // 圓形進度條的填充百分比 (範圍是 0.0 到 1.0)
  final Nutrients nutrient; // 用來顯示營養素的物件,包含名稱和重量

  // 構造函數,預設 percent 是 0.5
  CircleIndicator({this.percent = 0.5, required this.nutrient});

  @override
  _CircleIndicatorState createState() => _CircleIndicatorState();
}

// CircleIndicator 的狀態管理類別
class _CircleIndicatorState extends State<CircleIndicator>
    with SingleTickerProviderStateMixin {

  double fraction = 0.0; // 用來保存當前的動畫進度值
  late Animation<double> animation; // 動畫控制器,用來管理進度動畫

  @override
  void initState() {
    super.initState();
    // 初始化動畫控制器,設置動畫持續時間為 1000 毫秒
    var controller = AnimationController(
        duration: Duration(milliseconds: 1000), vsync: this);

    // 設置動畫,從 0.0 開始到 widget.percent 的結束值
    animation = Tween(begin: 0.0, end: widget.percent).animate(controller)
      ..addListener(() {
        setState(() {
          fraction = animation.value; // 隨著動畫進度更新 fraction
        });
      });

    controller.forward(); // 啟動動畫
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Stack(
        children: <Widget>[
          // 顯示營養素的名稱和重量
          Container(
            width: 70,
            height: 70,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                Text(
                  widget.nutrient.name, // 顯示營養素的名稱
                  style: TextStyle(color: Colors.white),
                ),
                Text(
                  widget.nutrient.weight, // 顯示營養素的重量
                  style: TextStyle(color: Colors.white),
                ),
              ],
            ),
          ),
          // 圓形進度條的外層容器,使用 CustomPaint 來繪製圓形
          Container(
            width: 70,
            height: 70,
            child: CustomPaint(
              foregroundPainter: CirclePainter(fraction), // 傳入當前進度值來繪製圓形
            ),
          ),
        ],
      ),
    );
  }
}

// CirclePainter 用來繪製圓形進度條
class CirclePainter extends CustomPainter {
  late Paint _paint; // 用來繪製圓形的畫筆
  double _fraction; // 當前的進度值

  CirclePainter(this._fraction) {
    // 初始化畫筆
    _paint = Paint()
      ..color = Color(0xff8DB646) // 設置圓形顏色
      ..strokeWidth = 5.0 // 設置圓形邊框寬度
      ..strokeCap = StrokeCap.round // 設置圓角
      ..style = PaintingStyle.stroke; // 只繪製邊框
  }

  @override
  void paint(Canvas canvas, Size size) {
    var rect = Offset(0.0, 0.0) & size; // 設定圓形範圍
    // 使用畫筆繪製圓形,根據 _fraction 設置繪製的角度
    canvas.drawArc(rect, -pi / 2, pi * 2 * _fraction, false, _paint);
  }

  @override
  bool shouldRepaint(CirclePainter oldDelegate) {
    return oldDelegate._fraction != _fraction; // 如果進度值有變化,則需要重繪
  }
}

增加details.dart

import 'package:flutter/material.dart';
import 'package:gitpod_flutter_quickstart/utils/class.dart';
import 'package:gitpod_flutter_quickstart/utils/widgets.dart';

// 詳細頁面 (DetailsPage) 負責顯示單一食譜的詳細資訊。
class DetailsPage extends StatelessWidget {
  final Recipe recipe; // 傳遞的食譜數據
  DetailsPage({required this.recipe});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: NestedScrollView(
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          return <Widget>[
            SliverAppBar(
              expandedHeight: 200.0, // 設置 SliverAppBar 的展開高度
              floating: false,
              pinned: true, // 讓 AppBar 保持固定
              title: Text(recipe.title), // 設置標題
              flexibleSpace: FlexibleSpaceBar(
                background: Hero(
                  tag: recipe.id, // 使用 Hero 動畫標籤
                  child: FadeInImage(
                    image: NetworkImage(recipe.imageUrl), // 加載食譜圖片
                    fit: BoxFit.cover,
                    placeholder: AssetImage('assets/images/loading.gif'), // 預設加載圖片
                  ),
                ),
              ),
            ),
          ];
        },
        body: Container(
          color: Theme.of(context).primaryColor,
          padding: EdgeInsets.only(top: 8.0), // 設置上方內邊距
          child: SingleChildScrollView(
            physics: BouncingScrollPhysics(), // 設置滾動效果
            child: Column(
              children: <Widget>[
                Text('Nutrition', // 營養標題
                    style: TextStyle(
                        color: Colors.white,
                        fontWeight: FontWeight.bold,
                        fontSize: 20)),
                NutritionWidget(
                  nutrients: recipe.nutrients,
                ),
                Divider(color: Colors.white, endIndent: 40.0, indent: 40.0),
                Text('Ingredients', // 成分標題
                    style: TextStyle(
                        color: Colors.white,
                        fontWeight: FontWeight.bold,
                        fontSize: 20)),
                IngredientsWidget(
                  ingredients: recipe.ingredients,
                ),
                Divider(color: Colors.white, endIndent: 40.0, indent: 40.0),
                Text('Steps', // 步驟標題
                    style: TextStyle(
                        color: Colors.white,
                        fontWeight: FontWeight.bold,
                        fontSize: 20)),
                RecipeSteps(
                  steps: recipe.steps,
                )
              ],
            ),
          ),
        ),
      ),
    );
  }
}

// 顯示食譜步驟的元件
class RecipeSteps extends StatelessWidget {
  final List<String> steps;
  RecipeSteps({this.steps = const []});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: steps.length,
      padding: const EdgeInsets.all(0.0),
      shrinkWrap: true,
      physics: ClampingScrollPhysics(),
      scrollDirection: Axis.vertical,
      itemBuilder: (BuildContext context, int index) {
        return ListTile(
            leading: CircleAvatar(
              backgroundColor: Theme.of(context).accentColor,
              child: Text('${index + 1}',
                  style: TextStyle(
                      color: Colors.black, fontWeight: FontWeight.bold)),
            ),
            title: Text(steps[index],
                style: TextStyle(
                    color: Colors.white,
                    fontWeight: FontWeight.bold,
                    fontSize: 16)));
      },
    );
  }
}

// 顯示成分列表的元件
class IngredientsWidget extends StatelessWidget {
  final List<String>? ingredients;
  IngredientsWidget({this.ingredients});

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 50,
      width: double.infinity,
      child: ListView.builder(
        itemCount: ingredients!.length,
        shrinkWrap: true,
        scrollDirection: Axis.horizontal,
        physics: BouncingScrollPhysics(),
        itemBuilder: (BuildContext context, int index) {
          return Padding(
            padding: const EdgeInsets.all(8.0),
            child: Chip(
              backgroundColor: Theme.of(context).accentColor,
              label: Text(ingredients![index],
                  style: TextStyle(
                      color: Colors.black, fontWeight: FontWeight.bold)),
            ),
          );
        },
      ),
    );
  }
}

// 顯示營養資訊的元件
class NutritionWidget extends StatelessWidget {
  final List<Nutrients>? nutrients;
  NutritionWidget({this.nutrients});
 
  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 86,
      width: double.infinity,
      child: Center(
        child: ListView.builder(
          itemCount: nutrients!.length,
          scrollDirection: Axis.horizontal,
          shrinkWrap: true,
          physics: BouncingScrollPhysics(),
          itemBuilder: (BuildContext context, int index) {
            return CircleIndicator(
              percent: nutrients![index].percent,
              nutrient: nutrients![index],
            );
          },
        ),
      ),
    );
  }
}

修改home.daart

import 'package:flutter/material.dart';
import 'package:gitpod_flutter_quickstart/screens/details.dart';
import 'package:gitpod_flutter_quickstart/utils/data.dart';

// HomePage 是一個無狀態 (Stateless) 小部件,負責顯示食譜的網格視圖。
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 使用 Container 作為網格視圖的背景容器
    var body = Container(
      color: Theme.of(context).primaryColor, // 設置背景顏色為主題顏色
      child: GridView.builder(
          shrinkWrap: false, // 允許 GridView 自適應內容大小
          itemCount: Data.recipes.length, // 根據食譜數量設置項目數量
          gridDelegate:
              SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2), // 設置兩列網格
          itemBuilder: (BuildContext context, int index) {
            return Padding(
              padding: const EdgeInsets.all(8.0), // 設置每個項目的間距
              child: InkWell(
                onTap: () {
                  // 點擊食譜卡片時,導航至詳細頁面
                  Navigator.push(
                      context,
                      MaterialPageRoute(
                          builder: (context) => DetailsPage(
                                recipe: Data.recipes[index],
                              )));
                },
                child: Card(
                  color: Theme.of(context).accentColor, // 設置卡片背景色
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(8.0), // 設置圓角
                  ),
                  child: Container(
                    width: MediaQuery.of(context).size.width / 2, // 設置寬度為螢幕寬度的一半
                    height: 50, // 設置固定高度
                    child: Column(
                      children: <Widget>[
                        Expanded(
                          child: ClipRRect(
                            borderRadius: BorderRadius.only(
                              topLeft: Radius.circular(8.0),
                              topRight: Radius.circular(8.0),
                            ), // 設置圖片的圓角
                            child: Hero(
                              tag: Data.recipes[index].id, // 設置 Hero 動畫標籤
                              child: FadeInImage(
                                image:
                                    NetworkImage(Data.recipes[index].imageUrl), // 從網路加載圖片
                                fit: BoxFit.cover, // 讓圖片填滿空間
                                placeholder:
                                    AssetImage('assets/images/loading.gif'), // 設置載入中的佔位圖片
                              ),
                            ),
                          ),
                        ),
                        Padding(
                          padding: const EdgeInsets.all(4.0), // 設置文字的間距
                          child: Text(
                            Data.recipes[index].title, // 顯示食譜標題
                            style: TextStyle(
                                color: Theme.of(context).primaryColor, // 設置文字顏色
                                fontSize: 20, // 設置字體大小
                                fontWeight: FontWeight.bold), // 設置字體加粗
                          ),
                        )
                      ],
                    ),
                  ),
                ),
              ),
            );
          }),
    );

    return Scaffold(
      appBar: AppBar(
        centerTitle: true, // 設置標題置中
        title: Text('Recipes'), // 設置 AppBar 標題
        actions: <Widget>[
          IconButton(
            icon: Icon(Icons.search), // 搜索按鈕
            onPressed: () {}, // 點擊事件 (目前未實作)
          )
        ],
      ),
      body: body, // 設置頁面主體內容
    );
  }
}










留言

此網誌的熱門文章

Angular 專案 - Employee Management管理系統

主題式英文單字學習|家居與建築篇