# 使用 flutter 建構 Expense Tracker app 原始碼:<https://github.com/WangShuan/flutter-03-expense_tracker> 該專案主要架構有一個 Appbar ,裏面顯示 app 標題以及 icon 按鈕,點擊 icon 按鈕後可開啟 modal 以新增消費紀錄; Appbar 下方則顯示圖表,分別列出每種類別的消費佔比;最後圖表下方則是列出所有消費紀錄的清單,其中有消費類別、標題、消費日期以及消費金額。 ## 定義 Expense 藍圖 `uuid` 套件可用來生成隨機的唯一 id,可通過指令 `flutter pub add uuid` 安裝套件,使用方式如下: ```dart // 引入套件 import 'package:uuid/uuid.dart'; class Expense { // 創建 Expense 藍圖,方便後續生成 Expense 列表 Expense({ required this.title, required this.price, required this.date, }) : id = Uuid().v4(); // 用 : id = xxx 設定初始值,並將初始值設為 Uuid().v4() final String id; final String title; final int price; final DateTime date; } ``` `enum` 用來定義一個類別以及該類別中可用的內容,舉例如下: ```dart // 建立一個類別 Category 並設置其可用的內容 enum Category { food, shopping, medical, learn, } // 指定 category 的類別為 Category // 所以 category 的值被限定為 food/shopping/medical/learn 四者其一 final Category category; ``` 定義與使用 category 的 icon : ```dart // 在 model/expense.dart 中定義 cateIcon 為各自類型所對應的 Icons const cateIcon = { Category.food: Icons.restaurant, Category.learn: Icons.menu_book, Category.medical: Icons.medical_services, Category.shopping: Icons.store }; // 使用 Icons CircleAvatar( child: Icon( cateIcon[expenseData.category], ), ) ``` 第三方套件 `intl` 用來格式化日期,可通過指令 `flutter pub add intl` 進行安裝,用法如下: ```dart // 通過 DateFormat() 設置想要顯示的格式,再用 format(date) 傳入 date 進行格式化 get formatterTime { return DateFormat('yyyy-MM-dd').format(date); } ``` ## 認識與使用新的小部件 不確定陣列長度時,可使用 `ListView.builder` 小部件,該小部件外層需具有寬高,如果外層為 `Row` 或 `Column` 時,需搭配 `Expanded` 或 `SizedBox` 小部件做使用: ```dart final List<Expense> expenseData; Expanded( child: ListView.builder( // 使用 ListView.builder 小部件 itemBuilder: (context, index) => Row( // 傳入 itemBuilder children: [ Text(expenseData[index].title), // 通過 index 獲取 expenseData 中的內容 ], ), itemCount: expenseData.length, // 設置 itemCount 為 expenseData 的長度 ), ) ``` 使用 `showModalBottomSheet` 方法從底部往上彈出 `modal` 以顯示表單新增消費紀錄: ```dart appBar: AppBar( // 在 Scaffold 小部件中設置 appbar title: const Text('Expenses Tracker'), // 設定 title actions: [ // 設定工具列的按鈕 IconButton( // 建立一個純 icon 的按鈕 onPressed: () { // 設定點擊後觸發的事件 showModalBottomSheet( // 這邊使用 showModalBottomSheet 開啟一個 modal context: context, // 傳入 context builder: (ctx) => const NewExpense(), // 設置 builder 回傳一個自定義的 NewExpense 表單小部件 ); }, icon: const Icon( // 設置 icon Icons.add, ), ), ], ), ``` 表單的輸入框要用 `TextField` 小部件,為了獲取輸入的內容,需設定小部件的 `controller` 屬性,並使用 `dispose()` 方法確保當關閉 `modal` 時同時銷毀控制器: ```dart final _titleController = TextEditingController(); // 建立輸入框控制器 @override void dispose() { // 使用 dispose() 方法 _titleController.dispose(); // 在不需要使用到 UI 時銷毀該控制器,以確保不會佔用到多餘的內存 super.dispose(); } // 綁定輸入框的控制器 TextField( controller: _titleController, // 綁定 controller decoration: const InputDecoration( // 設置樣式 label: Text('標題'), // 設置 label ), maxLength: 30, // 設置輸入內容的最大長度限制 ), // 獲取值 print(_titleController.text); ``` 另外在使用 `TextField` 時要注意,如果父部件為 `Row` 或 `Column`,需將 `TextField` 用 `Expanded` 小部件包住,否則會出現錯誤: ```dart Row( children: [ Expanded( child: TextField( controller: _priceController, decoration: const InputDecoration( labelText: '消費價格', prefixText: 'NT\$', ), maxLength: 6, keyboardType: const TextInputType.numberWithOptions(signed: true), // 設定鍵盤類型為數字+符號(因為純 TextInputType.number 鍵盤類型會沒送出按鈕) textInputAction: TextInputAction.done, // 設定點擊送出按鈕後執行的事件為完成,會關閉鍵盤並提交(如果是 TextInputAction.next 則會跳去下一個輸入框小部件並開啟其鍵盤類型) onSubmitted: (value) => _submitData(), // 設定送出時要執行的事件 ), ), const SizedBox( width: 30, ), // ... ], ), ``` 點擊按鈕關閉 `modal` 的方法: ```dart ElevatedButton( onPressed: () { Navigator.pop(context); // 設定這行用來關閉 modal }, child: const Text('CANCEL'), ) ``` 點擊按鈕開啟日期選擇器(使用 `showDatePicker` 方法)及顯示選中日期的做法: ```dart DateTime? _selectedDate_; // 聲明一個變量保存選中的日期,類型為 null 或 DateTime void _chooseDate() async { // 設定開啟日期選擇器的事件 final d = await showDatePicker( // 通過 await 方法獲取選中的日期,通過 showDatePicker 方法開啟日期選擇器 context: context, // 傳入 context initialDate: DateTime.now(), // 設置預設選中的日期 firstDate: DateTime(2000), // 設置最小日期 lastDate: DateTime.now(), // 設置最大日期 ); setState(() { // 通過 setState 更新日期並顯示在畫面上 _selectedDate_ = d; }); } // 使用 ElevatedButton.icon( // 建立一個帶 icon 的按鈕小部件 onPressed: _chooseDate, // 設置點擊後觸發的事件為 開啟日期選擇器的事件 label: _selectedDate_ != null // 判斷是否有選中日期,有就顯示選中的日期,沒有就顯示請選擇 ? Text(DateFormat('yyyy-MM-dd') // 用 intl 套件格式化日期 .format(_selectedDate_!) // 在可能為空的值後加上 ! 告訴 DART 這永遠不會是空值 .toString()) : const Text('請選擇日期'), icon: const Icon( // 設置 icon Icons.calendar_today, size: 15, ), ), ``` `DropdownButton` + `DropdownMenuItem` 小部件用來顯示類別的下拉選項: ```dart // 宣告變量 _selectCate 為第一個 Category Category? _selectCate = Category.values[0]; // 使用 Container( // 建立 Container 小部件當外框並設定樣式 padding: const EdgeInsets.symmetric( horizontal: 15, // 設定水平的 padding ), height: 40, // 設定高度 width: 120, // 設定寬度 decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), // 設定圓角弧度 color: const Color.fromARGB(255, 240, 230, 250), // 設定底色 ), child: DropdownButton<Category>( // 使用 DropdownButton 小部件生成下拉選單 underline: const SizedBox(), // 設置下拉選單的外觀樣式 isExpanded: true, // 設置擴展為 true(前提是父部件有設定寬度) items: Category.values // 設定下拉選項內容,這邊用 Category.values 遍歷 .map( (e) => DropdownMenuItem<Category>( // 回傳 DropdownMenuItem 小部件 value: e, // 設定選項的 value child: Row( // 設定 DropdownMenuItem 小部件的內容 children: [ Icon( cateIcon[e], size: 18, color: Theme.of(context).primaryColor, ), const SizedBox( width: 8, ), Text( cateZhName[e].toString(), style: textStyle, ), ], ), ), ) .toList(), // 最後用 toList() 轉為 [ DropdownMenuItem(), DropdownMenuItem()... ] value: _selectCate, // 設定選中的選項 icon: const Icon( Icons.arrow_drop_down_rounded, size: 15, ), onChanged: (value) { // 每次選中項目時觸發 onChanged,並會傳入 DropdownMenuItem 的 value setState(() { // 將選中的狀態設為 value _selectCate = value; }); }, ), ) ``` 撰寫提交表單的事件,如驗證無誤則添加消費紀錄、如驗證有誤則通過 `showDialog` 方法顯示 `AlertDialog` 小部件: ```dart void _submitData() { // 建立一個提交表單用的事件 if (_titleController.text.trim().isEmpty || // 判斷去除多餘空白後,是否有輸入標題 _priceController.text.trim().isEmpty || // 判斷去除多餘空白後,是否有輸入價格 int.parse(_priceController.text) <= 0 || // 判斷將文字轉為數字後是否有內容且小於等於零 _selectedDate == null) { // 判斷是否有選擇日期 showDialog( // 顯示對話框 context: context, builder: (context) => AlertDialog( // 顯示警告對話框小部件 title: const Text('格式錯誤'), // 設定標題 content: const Text('請確保您輸入了有效的標題、消費價格、消費日期與消費類別。'), // 設置警告內容 actions: [ TextButton( // 設置功能按鈕 onPressed: () { Navigator.pop(context); // 點擊後關閉對話框 }, child: const Text('確定'), ) ], ), ); return; // 返回,不執行下方程式碼 } // 如果上方驗證沒問題則觸發以下程式碼 widget.addData(Expense( // 這邊的 addData 是從 expenses.dart 傳入的,用來新增消費紀錄資料 title: _titleController.text.trim(), price: int.parse(_priceController.text), date: _selectedDate!, category: _selectCate!, )); Navigator.pop(context); // 新增後關閉 modal } ``` 優化 `modal` 讓鍵盤不會擋到內容的兩種方法: 第一種,讓 `modal` 佔據完整可用高度,程式碼如下: ```dart showModalBottomSheet( context: context, builder: (ctx) => NewExpense(_addData), isScrollControlled: true, // 新增這個設定,讓 modal 佔據完整的可用高度 ); ``` > 當 `modal` 佔據完整可用高度後,會發現部分內容將被手機本身的手機本身的功能遮蓋,比如相機鏡頭等等,所以需要讓 `modal` 的 `padding-top` 預留多一些空間,以確保能完整瀏覽 `modal` 的內容。 第二種,通過 `MediaQuery.of(context).viewInsets.bottom` 獲取鍵盤高度,並利用 `padding-bottom` 讓 `modal` 的內容往上推,程式碼如下: ```dart showModalBottomSheet( context: context, builder: (ctx) => Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, // 設置 paddint-bottom 為鍵盤高度 ), child: NewExpense(_addData), ), isScrollControlled: true, ); ``` 左/右滑動觸發事件可使用 `Dismissible` 小部件來實現,以本例來說,可通過滑掉單筆消費紀錄將該筆紀錄刪除: ```dart void removeExpenses(Expense expense) { setState(() { expensesData.remove(expense); }); } Dismissible( key: ValueKey(expenseData[i].id), // 設置唯一 key child: ExpenseItem(expenseData[i]), // 設定滑動的小部件,這邊是單筆消費紀錄 direction: DismissDirection.startToEnd, // 設定觸發事件的滑動方向,其他滑動方向則不會觸發任何事 onDismissed: (direction) => removeExpenses(expenseData[i]), // 設置滑動後要執行的事件 ) ``` 在滑動觸發刪除消費紀錄事件後,產生已刪除的提示訊息,可用 `SnackBar` 小部件: ```dart ScaffoldMessenger.of(context).clearSnackBars(); // 再產生新的 SnackBar 之前先清除舊的 ScaffoldMessenger.of(context).showSnackBar( // 顯示一個 SnackBar SnackBar( // 傳入 SnackBar 小部件 content: Text('已刪除 ${expense.title} 消費。'), // 設置訊息內容 duration: const Duration(seconds: 3), // 設置顯示時長,三秒後會自動消失 action: SnackBarAction( // 設置按鈕 label: '復原', // 按鈕名稱 onPressed: () { // 點擊後執行的事件 setState(() { expensesData.add(expense); }); }), ), ); ``` ## 自定義主題樣式 可設定使用 Material V3 版本的主題(默認是 V2 版本的主題,即主色為藍色的那種) ```dart MaterialApp( theme: ThemeData(useMaterial3: true), // 在 MaterialApp 裏面添加這行設定 home: const Expenses(), ), ``` 可設置種子顏色去生成一組相關聯的顏色,以便用於應用程式的主題設置: ```dart // 通過 ColorScheme.fromSeed 方法傳入 seedColor 為種子顏色進行生成 var kColorS = ColorScheme.fromSeed( seedColor: Colors.lightGreenAccent.shade100, ); ``` 可通過在 `MaterialApp` 小部件中設定 `theme` 來客製化主題樣式: ```dart MaterialApp( theme: ThemeData().copyWith( // 使用 ThemeData().copyWith 以保留預設的 Material 主題並加以改寫 useMaterial3: true, colorScheme: kColorS, // 指定應用程式主題中的各種顏色為我們用種子顏色生成的顏色 primaryColor: kColorS.primary, // 設置主題的主要顏色為種子顏色中的主要顏色 appBarTheme: const AppBarTheme().copyWith( // 設置 appBar 小部件的主題(通過 .copyWith 保留預設的樣式) foregroundColor: kColorS.onPrimary, // 更改前景色 backgroundColor: kColorS.primary, // 更改背景色 ), cardTheme: const CardTheme().copyWith( // 設置 Card 小部件的主題(通過 .copyWith 保留預設的樣式) elevation: 0.5, // 更改陰影程度 color: kColorS.secondaryContainer, // 設置背景色 ), filledButtonTheme: FilledButtonThemeData( // 設置 FilledButton 小部件的主題 style: FilledButton.styleFrom( // 通過 FilledButton.styleFrom 保留預設的樣式 elevation: 1, // 更改陰影程度 shadowColor: kColorS.background, // 更改陰影顏色 backgroundColor: kColorS.primary, // 更改 fill 的顏色 ), ), textTheme: ThemeData().textTheme.copyWith( // 設置所有文字相關小部件的主題(通過 .copyWith 保留預設的樣式) titleLarge: TextStyle( fontSize: 20, color: kColorS.primary, fontWeight: FontWeight.bold, height: 1, ), bodyLarge: TextStyle( fontSize: 16, color: kColorS.primary, height: 1, ), bodyMedium: TextStyle( color: kColorS.primary, height: 1, ), bodySmall: TextStyle( height: 1, color: kColorS.secondary, ), ), ), home: const Expenses(), ); ``` 如果需要特別針對某個文字小部件選用文字主題可以這樣做: ```dart Text( 'Records', style: Theme.of(context).textTheme.titleLarge, ), ``` 設置黑暗模式: ```dart var kDarkColorS = ColorScheme.fromSeed( // 新增一個給黑暗模式用的種子顏色 seedColor: const Color.fromARGB(255, 42, 96, 8), brightness: Brightness.dark, // 設定要生成深色主題用的顏色方案(默認是 Brightness.light) ); MaterialApp( themeMode: ThemeMode.system, // 設置主題的模式為手機系統的模式(以哀鳳來說可在手機的設定中點擊開發者切換深色外觀) darkTheme: ThemeData.dark().copyWith( // 使用 ThemeData().dark().copyWith 以保留預設的 Material 的深色主題並加以改寫 useMaterial3: true, colorScheme: kDarkColorS, // 指定應用程式主題中的各種顏色為我們用種子顏色生成的顏色 primaryColor: kDarkColorS.primary, // 設置主題的主要顏色為種子顏色中的主要顏色 scaffoldBackgroundColor: kDarkColorS.background, // 設置 Scaffold 小部件的背景色 appBarTheme: const AppBarTheme().copyWith( // 設置 appBar 小部件的主題(通過 .copyWith 保留預設的樣式) foregroundColor: kDarkColorS.primary, // 更改前景色 backgroundColor: kDarkColorS.onPrimary, // 更改背景色 ), // ...後略 ), home: const Expenses(), ) ``` 在小部件中判斷當前的主題模式以顯示不同的顏色: ```dart TextStyle( fontSize: 14, color: Theme.of(context).brightness == Brightness.dark ? kDarkColorS.onPrimaryContainer : const Color.fromARGB(255, 55, 55, 55), ); ``` ## 顯示統計圖表的部分 在 `models/expense.dart` 檔案中添加設定 ExpenseBucket 的藍圖,用來獲取每個類別的數據: ```dart class ExpenseBucket { ExpenseBucket(this.category, this.expenses); final Category category; // 傳入類別 final List<Expense> expenses; // 傳入 Expense 的列表 // 添加方法用來針對單個類別獲取數據 ExpenseBucket.fromCategory(List<Expense> allExpense, this.category) : expenses = allExpense .where((element) => element.category == category) .toList(); // 添加獲取總金額的方法 int get totalExpenses { int sum = 0; for (final e in expenses) { sum += e.price; } return sum; } } ``` ### 水平圖表作法 建立 `chart.dart` 檔案用來顯示統計圖表: ```dart final List<Expense> expenses; // 傳入所有的 Expense 數據 List<ExpenseBucket> get allCategory { // 通過 get 方法獲取各個類別的 ExpenseBucket 數據並整合為 List return [ ExpenseBucket.fromCategory(expenses, Category.food), ExpenseBucket.fromCategory(expenses, Category.learn), ExpenseBucket.fromCategory(expenses, Category.medical), ExpenseBucket.fromCategory(expenses, Category.shopping), ]; } int total = 0; // 宣告一個變量用來存放總金額 for (var item in allCategory) { // 使用 for in 方法計算總金額 total += item.totalExpenses; // 使用 ExpenseBucket 中的 totalExpenses 方法得到每個類別的總金額並進行加總 } // 用 `Container` 小部件設置外觀 Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), color: Theme.of(context).brightness == Brightness.dark ? kDarkColorS.onPrimary : kColorS.secondaryContainer, border: Border.all( width: 1, color: Theme.of(context).brightness == Brightness.dark ? kDarkColorS.primary : kColorS.primary, ), ), padding: const EdgeInsets.symmetric( vertical: 8, ), child: Column( // 用 Column 小部件生成由上到下的排列 children: [ ...allCategory.map((e) => ChartBar(e, total, allCategory)), // 通過 map 方法產生各個分類的圖表小部件 ], ), ); ``` 新增自訂義的圖表小部件 `chart_bat.dart` : ```dart final ExpenseBucket item; // 各個分類的數據 final int total; // 總金額 final List<ExpenseBucket> allCategory; // 所有分類的 ExpenseBucket 列表 Row( children: [ Column( // 用 Column 小部件顯示分類的 icon 與標題 children: [ Icon( cateIcon[item.category], size: 20, color: Theme.of(context).brightness == Brightness.dark ? kDarkColorS.primary : kColorS.onPrimaryContainer, ), const SizedBox( height: 4, ), Text( cateZhName[item.category]!, style: TextStyle( color: Theme.of(context).brightness == Brightness.dark ? kDarkColorS.primary : kColorS.onPrimaryContainer, fontSize: 12, ), ) ], ), const SizedBox( width: 8, ), Expanded( // 用 Expanded 小部件確保圖表佔據最大可用寬度 child: ClipRRect( // 用 ClipRRect 小部件設置圓角外觀 borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( // 用 LinearProgressIndicator 小部件生成百分比水平條狀圖 value: (item.totalExpenses.toDouble() / total), // 設置填色區塊為 類別總金額➗總金額 minHeight: 16, // 設置水平條狀圖的高度 color: Theme.of(context).brightness == Brightness.dark // 設置填色區塊的顏色 ? kDarkColorS.primary : kColorS.primary, backgroundColor: // 設置為填色區塊的背景色 Theme.of(context).brightness == Brightness.dark ? kDarkColorS.primaryContainer : kColorS.primary.withOpacity(0.3), ), ), ), ], ); ``` 最後可用 `Column` 小部件將整個 `Row` 小部件包起來,並在每個類別的 `Row` 下方新增分隔線小部件 `Divider`: ```dart Column( children: [ Padding( padding: const EdgeInsets.symmetric( horizontal: 16, ), child: Row( children: [ Column( // 顯示類別的 icon 與名稱 ), const SizedBox( width: 8, ), Expanded( // 顯示水平條狀圖 ), ], ), ), if (allCategory.last.category != item.category) // 判斷是否為最後一個分類,不是才顯示分隔線 const Divider(), // 分隔線小部件 ], ); ``` ## 垂直圖表做法 將 `Chart.dart` 的 `Column` 改為 `Row`: ```dart Row( // 用 Row 小部件生成由左到右的排列 crossAxisAlignment: CrossAxisAlignment.end, // 設置內容對齊方式為垂直貼底 children: [ ...allCategory.map( (e) => Expanded( // 將 ChartBar 小部件用 Expanded 包住,以佔據所有空間 child: ChartBar(e, total, maxTotal, allCategory), ), ), ], ), ``` 將 `chart_bat.dart` 最外層的 `Row` 改為 `Column` ,讓類別名稱與填色區塊垂直排列,接著將原本用來設置 `LinearProgressIndicator` 小部件外觀圓角的 `ClipRRect` 小部件刪除,並將 `LinearProgressIndicator` 改為 `FractionallySizedBox` 小部件,整體內容如下: ```dart final ExpenseBucket item; // 各個分類的數據 final int total; // 總金額 final int maxTotal; // 分類當中最高的金額 Column( mainAxisAlignment: MainAxisAlignment.end, // 設置讓條狀圖統一靠下 children: [ Column( // 用來在填色條狀上方顯示類別名稱/圖標/所佔據百分比 children: [ Icon( // 顯示類別 icon cateIcon[item.category], size: 20, color: isDarkMode ? kDarkColorS.primary : kColorS.onPrimaryContainer, ), const SizedBox( height: 4, ), Text( // 顯示類別名稱 item.category.name.toUpperCase(), style: TextStyle( color: isDarkMode ? kDarkColorS.primary : kColorS.onPrimaryContainer, fontSize: 12, fontWeight: FontWeight.w500, ), ), const SizedBox( height: 4, ), Text( // 用總金額下去計算,顯示類別佔據的百分比 '${(item.totalExpenses.toDouble() / total * 100).floor()}%', style: Theme.of(context) .textTheme .bodyMedium ?.copyWith(fontWeight: FontWeight.bold), ) ], ), const SizedBox( height: 4, ), Flexible( // 使用 Flexible 將 FractionallySizedBox 小部件包住 child: FractionallySizedBox( // 使用 FractionallySizedBox 小部件顯示填色條狀圖 heightFactor: item.totalExpenses.toDouble() / maxTotal, // 要填色的高度 child: Container( // 用 Container 小部件進行填色 width: 24, margin: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( borderRadius: const BorderRadius.vertical( top: Radius.circular(4), bottom: Radius.circular(0), ), color: isDarkMode ? kDarkColorS.onPrimaryContainer : kColorS.primary, ), ), ), ), ], ); ``` ## 響應式設計 獲取設備寬高的方式如下: ```dart // 比如可判斷 width > 400 時顯示 Row 小部件、 width < 400 時顯示 Column 小部件 final width = MediaQuery.of(context).size.width; // 比如可判斷 height > 400 時顯示 Column 小部件、 height < 400 時顯示 Row 小部件 final height = MediaQuery.of(context).size.height; ``` 獲取小部件寬高的方式: ```dart LayoutBuilder( // 使用 LayoutBuilder 小部件包住欲計算的小部件 builder: (BuildContext context, BoxConstraints constraints) { return Padding( padding: const EdgeInsets.symmetric( vertical: 16, horizontal: 8, ), child: SizedBox( width: constraints.maxWidth * 0.55, // 通過 constraints.maxWidth 獲取寬度 // constraints 共有 maxWidth/minWidth/maxHeight/minHeight 四種屬性值 child: Column( children: [ Text( 'Records', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox( height: 8, ), mainContent, ], ), ), ), }, ); ``` 判斷設備當前方向為直立或橫向: ```dart final orientation = MediaQuery.of(context).orientation; if (orientation == Orientation.portrait) // 直立 Row( children: [ Expanded(child: priceWidget), // 把消費價格的輸入框小部件設為 local variable 方便復用 const SizedBox(width: 16), const Spacer() ], ) else // 橫向 Row( children: [ Expanded(child: titleWidget), // 把標題的輸入框小部件設為 local variable 方便復用 const SizedBox(width: 16), SizedBox(width: 200, child: priceWidget), // 把消費價格的輸入框小部件設為 local variable 方便復用 ], ), ``` ## 自適應 安卓設備使用 `Material` 主題,蘋果設備使用 `Cupertino` 主題,比如 `showDialog` vs `showCupertinoDialog` ,可通過以下方式判斷設備: ```dart if (Platform.isIOS) { // 判斷是否為 IOS 系統 showCupertinoDialog( context: context, builder: (context) => CupertinoAlertDialog( title: const Text('格式錯誤'), content: const Text('請確保您輸入了有效的標題、消費價格、消費日期與消費類別。'), actions: [ TextButton( onPressed: () { Navigator.pop(context); }, child: const Text('確定'), ) ], ), ); } else { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('格式錯誤'), content: const Text('請確保您輸入了有效的標題、消費價格、消費日期與消費類別。'), actions: [ TextButton( onPressed: () { Navigator.pop(context); }, child: const Text('確定'), ) ], ), ); } ```