作为一个会做饭的程序员,每天给女朋友和自己带饭是必须的,可是每天要吃什么却是一个世纪难题!
以前就想过要开发一个APP,来随机决定明天吃什么菜,然而世界上最痛苦的事情是:
我是一个 Android 开发崽,而女朋友用的是 iPhone!这难道就是世界上最遥远的距离吗?!
就在这时,Flutter 来了,它带着耀眼的光芒和风骚的话语:来啊!上我啊!
这™不上还是男人?
APP 展示
APP基本上一个整天就开发完成了,后续进行了一系列的需求调整,先来看图:
菜品展示
简单放几个🤣
确定需求
从上面可以看到一共有四个功能:
1.随机选菜,并且可以单独随机某一个2.确认并保存截图到手机3.查看所有菜谱和菜谱使用的时间4.添加新的菜谱
还有一个功能没有体现出来,其实也是比较重要的功能:
七天之内不能有重复的菜出现。
代码实现
我们逐个功能来看,首先看一下首页随机选菜。
随机选菜功能
页面看似很简单,一个 Column 包裹住就 OK,但实际呢?
首先确定我们的需求,该功能就是一个随机选菜的功能,那逻辑如下:
1.先定义数据,然后点击选菜2.荤菜 素菜 全部随机 并附带随机效果
定义数据
该数据为个人所有会做的菜品,并且自己分类为 荤菜 还是 素菜。
定义好数据后,因为考虑到后续有添加新菜的功能,使用 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毫秒就改变一下菜名,来达到随机的效果。
确认并保存截图到手机
该需求是女朋友后续提出来的,因为每次确认使用后,都需要手动保存图片,然后微信分享给我,所以添加了这个功能。
这样就不用每次都手动保存图片了。
该功能有如下三个小点:
1.如何保存截图2.显示截图3.保存截图到手机
如何保存截图
首先说如何保存截图,关于该功能,我也是网上查找资料所得,
地址为:FengY - Flutter学习 ---- 屏幕截图和高斯模糊[1]
这里我也简单说一下,具体可以查看该文章:
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);
}
七天之内不能出现重复菜品
该功能也是后续添加的,因为毕竟谁也不想每天在软件上点菜都有重复:我昨天吃红烧肉了,今天还吃?
该功能也有几个小难点:
1.SharedPreferences
不能存储对象2.如何判断已经过了七天?
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
方法来判断两个日期之间的差。
这样就能判断出来是否已经被使用过了。
查看所有菜谱和菜谱使用的时间
该功能主要为装逼所用,别人一看:卧槽,会做这么多菜,牛逼🐂🍺。
该功能其实也有几个需要注意的点:
1.如何展示素菜和荤菜2.如何实时更新已经使用过/新增的菜?
如何展示素菜和荤菜
这里我选用的是 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进行一系列的功能优化,比如:
•写个后台存储菜谱•增加菜品图片•优化随机效果?
References
[1]
FengY - Flutter学习 ---- 屏幕截图和高斯模糊: https://juejin.im/post/5b03ea7e51882565bd2594b0