作為一個會做飯的程式設計師,每天給女朋友和自己帶飯是必須的,可是每天要吃什麼卻是一個世紀難題!
以前就想過要開發一個APP,來隨機決定明天吃什麼菜,然而世界上最痛苦的事情是:
我是一個 Android 開發崽,而女朋友用的是 iPhone!這難道就是世界上最遙遠的距離嗎?!
就在這時,Flutter 來了,它帶著耀眼的光芒和風騷的話語:來啊!上我啊!
這™不上還是男人?
APP 展示
APP基本上一個整天就開發完成了,後續進行了一系列的需求調整,先來看圖:
菜品展示
簡單放幾個?
確定需求
從上面可以看到一共有四個功能:
- 隨機選菜,並且可以單獨隨機某一個
- 確認並儲存截圖到手機
- 檢視所有菜譜和菜譜使用的時間
- 新增新的菜譜
還有一個功能沒有體現出來,其實也是比較重要的功能:
七天之內不能有重複的菜出現。
程式碼實現
我們逐個功能來看,首先看一下首頁隨機選菜。
隨機選菜功能
頁面看似很簡單,一個 Column 包裹住就 OK,但實際呢?
首先確定我們的需求,該功能就是一個隨機選菜的功能,那邏輯如下:
- 先定義資料,然後點選選菜
- 葷菜 素菜 全部隨機 並附帶隨機效果
定義資料
該資料為個人所有會做的菜品,並且自己分類為 葷菜 還是 素菜。
定義好資料後,因為考慮到後續有新增新菜的功能,使用 SharedPreferences
儲存起來,
每次開啟APP的時候先判斷一下是否有快取,如果有快取則用快取,沒有則存入。
隨機選菜並附帶隨機效果
該功能我們也需要考慮一下,從上圖也可以看到,會多次隨機菜品,然後重新整理頁面,
那這個時候肯定不能用 setState()
,因為 setState()
會多次 build 我們的頁面,這樣很不優雅。
BLoC模式
所以我決定使用 BLoC 模式,因為不需要在其他頁面使用,所以就定義了一個區域性的:
class RandomMenuBLoC {
StreamController<String> _meatController;
StreamController<String> _greenController;
Random _random;
RandomMenuBLoC() {
_meatController = StreamController();
_greenController = StreamController();
_random = Random();
}
Stream<String> get meatStream => _meatController.stream;
Stream<String> get greenStream => _greenController.stream;
random(BuildContext context) async {
var meatData = ScopedModel.of<DishModel>(context).meatData;
var greenStuffData = ScopedModel.of<DishModel>(context).greenStuffData;
for (int i = 0; i < 20; i++) {
await Future.delayed(new Duration(milliseconds: 50), () {
return "${meatData.length == 0 ? "暫無可用菜品" : meatData[_random.nextInt(meatData.length)].name}+${greenStuffData.length == 0 ? "暫無可用菜品" : greenStuffData[_random.nextInt(greenStuffData.length)].name}";
}).then((s) {
_meatController.sink.add(s.substring(0, s.indexOf("+")));
_greenController.sink.add(s.substring(s.indexOf("+")+1));
});
}
}
randomMeat(BuildContext context) async{
var meatData = ScopedModel.of<DishModel>(context).meatData;
for (int i = 0; i < 20; i++) {
await Future.delayed(new Duration(milliseconds: 50), () {
return "${meatData.length == 0 ? "暫無可用菜品" : meatData[_random.nextInt(meatData.length)].name}";
}).then((s) {
_meatController.sink.add(s);
});
}
}
randomGreen(BuildContext context) async{
var greenStuffData = ScopedModel.of<DishModel>(context).greenStuffData;
for (int i = 0; i < 20; i++) {
await Future.delayed(new Duration(milliseconds: 50), () {
return "${greenStuffData.length == 0 ? "暫無可用菜品" : greenStuffData[_random.nextInt(greenStuffData.length)].name}";
}).then((s) {
_greenController.sink.add(s);
});
}
}
dispose() {
_meatController.close();
_greenController.close();
}
}
複製程式碼
首先因為考慮到會單獨重新整理某一個資料,所以定義了兩個 streamController,一個素菜,一個葷菜。
然後下面就是隨機菜品的方法,通過 Future.delayed
來進行一個50毫秒的延時後返回葷菜和素菜隨機的結果,並且在 then
方法中呼叫 streamController.sink.add
來通知 stream 重新整理。
UI使用如下:
StreamBuilder(
stream: _bLoC.greenStream,
initialData: "選個菜吧",
builder: (context, snapshot) {
_greenName = snapshot.data;
return Text(
_greenName,
style: TextStyle(fontSize: 34, color: Colors.black87),
);
},
),
複製程式碼
這樣就完成了我們上圖的需求,每隔50毫秒就改變一下菜名,來達到隨機的效果。
確認並儲存截圖到手機
該需求是女朋友後續提出來的,因為每次確認使用後,都需要手動儲存圖片,然後微信分享給我,所以新增了這個功能。
這樣就不用每次都手動儲存圖片了。
該功能有如下三個小點:
- 如何儲存截圖
- 顯示截圖
- 儲存截圖到手機
如何儲存截圖
首先說如何儲存截圖,關於該功能,我也是網上查詢資料所得,
地址為:FengY - Flutter學習 ---- 螢幕截圖和高斯模糊
這裡我也簡單說一下,具體可以檢視該文章:
Flutter 獲取 widget 的截圖 使用到的是 RepaintBoundary
,程式碼如下:
return RepaintBoundary(
key: rootWidgetKey,
child: Scaffold(),
);
複製程式碼
通過 RepaintBoundary
包裹住 Scaffold
,然後給定一個 globalKey
,這樣就可以進行截圖了:
// 程式碼為 FengY 所寫
// 截圖boundary,並且返回圖片的二進位制資料。
Future<Uint8List> _capturePng() async {
RenderRepaintBoundary boundary = globalKey.currentContext.findRenderObject();
ui.Image image = await boundary.toImage();
// 注意:png是壓縮後格式,如果需要圖片的原始畫素資料,請使用rawRgba
ByteData byteData = await image.toByteData(format: ui.ImageByteFormat.png);
Uint8List pngBytes = byteData.buffer.asUint8List();
return pngBytes;
}
複製程式碼
呼叫該方法後,返回的就是一個 Future<Uint8List>
物件了,後續使用 Image.memory
方法即可顯示該圖片。
顯示截圖
從 gif 可以看到,在截圖以後會先顯示一個小菊花,然後彈出當前所截圖片,一會以後會消失,這裡使用的是 showDialog
配合 FutureBuilder
。
因為截圖會有一定的延時,並且返回值為一個 Future ,那我們沒有理由不用 FutureBuilder
,如有不瞭解 FutureBuilder
的,可以檢視我的這篇文章:Flutter FutureBuilder 非同步UI神器
大概程式碼如下:
showDialog(
context: context,
builder: (context) {
return FutureBuilder<Uint8List>(
future: _future,
builder: (BuildContext context,
AsyncSnapshot snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.active:
case ConnectionState.waiting:
return Center(
child: CupertinoActivityIndicator());
case ConnectionState.done:
_saveImage(snapshot.data);
Future.delayed(
Duration(milliseconds: 1500), () {
Navigator.of(context,rootNavigator: true).pop();
});
return Container(
margin:
EdgeInsets.symmetric(vertical: 50),
decoration: BoxDecoration(
borderRadius: BorderRadius.all(
Radius.circular(18)),
color: Colors.transparent,
),
child: Image.memory(snapshot.data),
);
}
},
);
});
複製程式碼
儲存截圖到手機
該功能使用的是 image_gallery_saver
庫,該庫通過呼叫原生方法來實現。由於要儲存圖片,所以必須要新增手機圖片讀寫許可權。
使用方法也很簡單,一行程式碼就搞定:
_saveImage(Uint8List img) async {
await ImageGallerySaver.save(img);
}
複製程式碼
七天之內不能出現重複菜品
該功能也是後續新增的,因為畢竟誰也不想每天在軟體上點菜都有重複:我昨天吃紅燒肉了,今天還吃?
該功能也有幾個小難點:
SharedPreferences
不能儲存物件- 如何判斷已經過了七天?
SharedPreferences 不能儲存物件
最開始的時候只是儲存了菜名,並沒有該菜是否已經使用,所以要定義一個物件來儲存資料,
後來發現SharedPreferences
不能儲存物件,那沒辦法,只能轉 json 了:
class Food {
String name;
String time;
bool isUsed;
Food(
this.name, {
this.time, // 確認吃的時間,用於七天自動過期
this.isUsed = false,
});
Map toJson() {
return {'name': this.name, 'time': this.time, 'isUsed': this.isUsed};
}
Food.fromJson(Map<String, dynamic> json) {
this.name = json['name'];
this.time = json['time'];
this.isUsed = json['isUsed'];
}
}
複製程式碼
由於是個小專案,直接就用的 jsonDecode
/ jsonEncode
,使用該方法的時候必須定義 fromJson
/ toJson
,否則會報錯。
如何判斷已經過了七天
經過查詢資料,發現 dart 中有一個 DateTime
類,該類的方法確實不少。
判斷過了七天的邏輯就是:獲取當前日期,獲取儲存的菜的使用日期,相減是否大於6
那我們在初始化菜的時候就可以判斷,迴圈所有的菜品,如果該菜品已經被使用,那麼則去判斷:
_meatData.forEach((f) {
if (f.isUsed) {
if (timeNow.difference(DateTime.parse(f.time)).inDays > 6) {
f.time = null;
f.isUsed = false;
}
}
});
複製程式碼
首先判斷該菜品是否被使用過,如果已經被使用過,則使用 DateTime.difference
方法來判斷兩個日期之間的差。
這樣就能判斷出來是否已經被使用過了。
檢視所有菜譜和菜譜使用的時間
該功能主要為裝逼所用,別人一看:臥槽,會做這麼多菜,牛逼??。
該功能其實也有幾個需要注意的點:
- 如何展示素菜和葷菜
- 如何實時更新已經使用過/新增的菜?
如何展示素菜和葷菜
這裡我選用的是 ExpansionPanelList
,用它來實現最合適不過。
如果你還沒有了解過 ExpansionPanelList
,那麼我建議讀我的這篇文章:Flutter ExpansionPanel 超級實用展開控制元件
剩下的就很簡單了,通過資料來判斷是否展示 已使用標識 和 已使用時間。
簡單程式碼如下:
return Padding(
child: Row(
children: <Widget>[
data.isUsed
? Icon(
Icons.done,
color: Colors.red,
)
: Container(),
Expanded(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12.0),
child: Text(
data.name,
style: TextStyle(fontSize: 16),
),
),
),
data.isUsed
? Text(
data.time.substring(0, data.time.indexOf('.')))
: Container(),
],
),
padding: EdgeInsets.all(20),
);
複製程式碼
如何實時更新已經使用過/新增的菜?
該功能就需要用到我們所說的狀態管理,這裡我使用的是 Scoped_Model
。
在首頁和該頁都會使用到該功能,當已經使用一個菜的時候,所有菜品裡應實時更新,新增菜品的時候也應如此。
使用菜品程式碼如下:
/// 確認使用該食物
useFood(String greenName, String meatName) {
var time = DateTime.now();
for (int i = 0; i < _greenStuffData.length; i++) {
if (_greenStuffData[i].name == greenName) {
_greenStuffData[i].isUsed = true;
_greenStuffData[i].time = time.toString();
break;
}
}
for (int i = 0; i < _meatData.length; i++) {
if (_meatData[i].name == meatName) {
_meatData[i].isUsed = true;
_meatData[i].time = time.toString();
break;
}
}
updateData('greenStuffData', _greenStuffData);
updateData('meatData', _meatData);
showToast('使用成功並儲存至相簿',
textStyle: TextStyle(fontSize: 20),
textPadding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
position: ToastPosition(align: Alignment.bottomCenter),
radius: 30,
backgroundColor: Colors.grey[400]);
notifyListeners();
}
複製程式碼
程式碼很簡單,就是兩個迴圈查詢,然後 notifyListeners()
。
新增新的菜譜
菜譜是自己寫的,如果女朋友想吃別的菜怎麼辦?新增啊!
這裡的彈出框使用的是 showModalBottomSheet
,但是用過該方法的人都知道 BottomSheetDialog
有個 bug,那就是鍵盤彈出框不能頂起佈局!
經過我不懈努力,終於,在網上找到了別人重寫的 showModalBottomSheetApp
。
可以順利彈起佈局了。然後在點選儲存時,呼叫 Scoped_Model 中增加菜譜方法。
總結
後續可能會對該APP進行一系列的功能優化,比如:
- 寫個後臺儲存菜譜
- 增加菜品圖片
- 優化隨機效果?
如果朋友們有什麼好的效果或者需求可以找我呀,我來實現看看?