整体布局

Flutter 实现钉钉打卡 - 图1

  1. @override
  2. Widget build(BuildContext context) {
  3. return SingleChildScrollView(
  4. child: Container(
  5. padding: EdgeInsets.symmetric(horizontal: 10).copyWith(top: 10),
  6. child: Column(
  7. children: [
  8. _buildFlipCard(),
  9. // 预留.... 可以放一些其他需要显示的
  10. ],
  11. ),
  12. ),
  13. );
  14. }

用到了第三方依赖

反转 flip_card: ^0.4.4 https://pub.flutter-io.cn/packages/flip_card

音频播放 just_audio: ^0.6.5 https://pub.flutter-io.cn/packages/just_audio

格式化时间 date_format: ^1.0.8 https://pub.flutter-io.cn/packages/date_format

FlipCard weiget 分为 front 和 back,可以实现前后旋转效果

  1. FlipCard(
  2. direction: FlipDirection.HORIZONTAL, // default
  3. front: Container(
  4. child: Text('Front'),
  5. ),
  6. back: Container(
  7. child: Text('Back'),
  8. ),
  9. );

_buildFlipCard 封装构建 FlipCard

构建 front 使用 Column 包裹,划分不同小的 widget

Flutter 实现钉钉打卡 - 图2

构建第一部分, 使用flutter 自带 Card 包裹,可以提现卡片效果
  1. Card(
  2. elevation: 0.0,
  3. child: Container(
  4. margin: EdgeInsets.symmetric(horizontal: 16.0),
  5. padding: EdgeInsets.symmetric(vertical: 16.0),
  6. child: Row(
  7. children: [
  8. Expanded(
  9. flex: 4,
  10. child: Container(
  11. child: Row(
  12. children: [
  13. ClipOval(
  14. child: Container(
  15. width: 60,
  16. height: 60,
  17. alignment: Alignment.center,
  18. color: kDTPrimary,
  19. child: Text(
  20. "江景",
  21. style: TextStyle(
  22. color: Colors.white,
  23. fontSize: 18.0,
  24. ),
  25. ),
  26. ),
  27. ),
  28. Container(
  29. margin: EdgeInsets.only(left: 16),
  30. child: Column(
  31. crossAxisAlignment: CrossAxisAlignment.start,
  32. children: [
  33. Text(
  34. "江景",
  35. style: TextStyle(fontSize: 24.0),
  36. ),
  37. Row(
  38. children: [
  39. Text("MOOSE"),
  40. Padding(
  41. padding: EdgeInsets.only(left: 8.0),
  42. child: Text(
  43. "(查看规则)",
  44. style: TextStyle(color: kDTPrimary),
  45. ),
  46. ),
  47. ],
  48. )
  49. ],
  50. ),
  51. )
  52. ],
  53. ),
  54. )),
  55. Expanded(
  56. flex: 1,
  57. child: Container(
  58. child: Text(
  59. "申请",
  60. style: TextStyle(fontSize: 16.0),
  61. ),
  62. )),
  63. ],
  64. ),
  65. ),
  66. ),

第二部分使用 Card 包裹,里边嵌套 Column
  1. Card(
  2. elevation: 0.0,
  3. child: Column(
  4. children: [
  5. Container(
  6. margin: EdgeInsets.only(
  7. top: 20.0,
  8. ),
  9. child: Row(
  10. mainAxisAlignment: MainAxisAlignment.spaceAround,
  11. children: [
  12. Container(
  13. width: 150,
  14. padding: EdgeInsets.symmetric(
  15. vertical: 8.0, horizontal: 8.0),
  16. margin: EdgeInsets.only(left: 8.0),
  17. decoration: BoxDecoration(
  18. color: kDTNormal,
  19. borderRadius:
  20. BorderRadius.all(Radius.circular(8.0))),
  21. child: Column(
  22. crossAxisAlignment: CrossAxisAlignment.start,
  23. children: [
  24. Text(
  25. "上班08:30",
  26. style: TextStyle(fontSize: 18),
  27. ),
  28. Row(
  29. children: [
  30. SvgPicture.asset(
  31. 'assets/icons/icon_right.svg',
  32. width: 32,
  33. color: kDTPrimary,
  34. ),
  35. Text("08:36已打卡")
  36. ],
  37. ),
  38. ],
  39. ),
  40. ),
  41. Expanded(
  42. flex: 1,
  43. child: Container(
  44. padding: EdgeInsets.symmetric(
  45. vertical: 8.0, horizontal: 8.0),
  46. margin: EdgeInsets.only(left: 8.0, right: 8.0),
  47. decoration: BoxDecoration(
  48. color: kDTNormal,
  49. borderRadius:
  50. BorderRadius.all(Radius.circular(8.0))),
  51. child: Column(
  52. crossAxisAlignment: CrossAxisAlignment.start,
  53. children: [
  54. Text(
  55. "下班 18:30(弹性)",
  56. style: TextStyle(fontSize: 18),
  57. ),
  58. Row(
  59. children: [
  60. SvgPicture.asset(
  61. 'assets/icons/icon_right.svg',
  62. width: 32,
  63. color: kDTPrimary,
  64. ),
  65. Row(
  66. children: [
  67. Text(
  68. "08:36已打卡",
  69. style: TextStyle(fontSize: 16.0),
  70. ),
  71. Text(
  72. "更新打卡",
  73. style: TextStyle(
  74. fontSize: 16.0, color: kDTPrimary),
  75. ),
  76. ],
  77. )
  78. ],
  79. ),
  80. ],
  81. ),
  82. ),
  83. )
  84. ],
  85. ),
  86. ),
  87. // 构建扫描部分
  88. _buildStack(),
  89. Container(
  90. margin: EdgeInsets.only(
  91. top: 20.0,
  92. bottom: 20.0,
  93. ),
  94. child: Text("已进入考勤范围 MOOSE"),
  95. ),
  96. ],
  97. ),
  98. ),

_buildStack() 部分

使用 Stack 层叠布局放置两个大小相同 ClipOval widget

第一个 Container decoration 使用 LinearGradient 渐变

第二个 Container decoration 使用 SweepGradient 渐变

给第二个加上 RotationTransition 动画效果,控制隐藏和显示

给第一个加上 GestureDetector 事件触发动画执行

  1. Container(
  2. padding: EdgeInsets.only(top: 60.0),
  3. child: Stack(
  4. children: [
  5. Center(
  6. child: GestureDetector(
  7. onTap: _onClick,
  8. child: ClipOval(
  9. child: Container(
  10. width: 180,
  11. height: 180,
  12. alignment: Alignment.center,
  13. decoration: BoxDecoration(
  14. color: kDTPrimary,
  15. gradient: LinearGradient(
  16. begin: Alignment.topCenter,
  17. end: Alignment.topCenter,
  18. colors: [
  19. kDTPrimary.withOpacity(0.8),
  20. kDTPrimary.withOpacity(1),
  21. ]),
  22. ),
  23. child: Column(
  24. mainAxisAlignment: MainAxisAlignment.center,
  25. children: [
  26. Text(
  27. "上班打卡",
  28. style: TextStyle(
  29. color: Colors.white, fontSize: 24),
  30. ),
  31. Text(_currentTime,
  32. style: TextStyle(
  33. color: Colors.white, fontSize: 24)),
  34. ],
  35. ),
  36. ),
  37. ),
  38. ),
  39. ),
  40. Center(
  41. child: _show
  42. ? RotationTransition(
  43. turns: _animationController,
  44. child: ClipOval(
  45. child: Container(
  46. width: 180,
  47. height: 180,
  48. decoration: BoxDecoration(
  49. gradient: SweepGradient(colors: [
  50. Colors.white.withOpacity(0.4),
  51. Colors.white.withOpacity(0.6),
  52. ]),
  53. ),
  54. ),
  55. ),
  56. )
  57. : Container(),
  58. )
  59. ],
  60. ),
  61. ),

初始化定时器 Timer,动画控制器 AnimationController
  1. class _DTWorksetBodyState extends State<DTWorksetBody>
  2. with SingleTickerProviderStateMixin {
  3. GlobalKey<FlipCardState> _flipCardKey = GlobalKey<FlipCardState>();
  4. AnimationController _animationController;
  5. bool _show = false;
  6. Timer _timer;
  7. String _currentTime = formatDate(DateTime.now(), [HH, ':', nn, ':', ss]);
  8. String _downTime = formatDate(DateTime.now(), [HH, ':', nn, ':', ss]);
  9. .......
  10. @override
  11. void initState() {
  12. super.initState();
  13. _animationController = AnimationController(
  14. vsync: this, duration: Duration(milliseconds: 2000));
  15. _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
  16. setState(() {
  17. _currentTime = formatDate(DateTime.now(), [HH, ':', nn, ':', ss]);
  18. });
  19. });
  20. }

点击事件
  1. void _onClick() {
  2. // 控制第二个圆 显示
  3. setState(() {
  4. _show = true;
  5. });
  6. Future.delayed(Duration.zero, () {
  7. _animationController.repeat();
  8. Future.delayed(Duration(seconds: 3), () {
  9. setState(() {
  10. _downTime = formatDate(DateTime.now(), [HH, ':', nn, ':', ss]);
  11. // 结束扫描动画
  12. _animationController.stop();
  13. // 使用 FlipCard 开始反转
  14. _flipCardKey.currentState.toggleCard();
  15. // 隐藏扫描 widget
  16. _show = false;
  17. });
  18. });
  19. });
  20. }

FlipCard back 视图简单实现
  1. back: Card(
  2. elevation: 0.0,
  3. child: Container(
  4. padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
  5. child: Column(
  6. children: [
  7. Row(
  8. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  9. children: [
  10. Column(
  11. crossAxisAlignment: CrossAxisAlignment.start,
  12. children: [
  13. Text(
  14. "下班打卡成功",
  15. style: TextStyle(fontSize: 26.0),
  16. ),
  17. Padding(
  18. padding: const EdgeInsets.all(8.0),
  19. child: Text(
  20. "打卡时间 $_downTime",
  21. style: TextStyle(fontSize: 16.0),
  22. ),
  23. ),
  24. ],
  25. ),
  26. GestureDetector(
  27. child: Icon(Icons.close),
  28. onTap: () {
  29. // 控制反转 FlipCard
  30. _flipCardKey.currentState.toggleCard();
  31. },
  32. )
  33. ],
  34. )
  35. ],
  36. ),
  37. ),
  38. ),

加上音效

初始化 AudioPlayer

  1. AudioPlayer _player;
  2. @override
  3. void initState() {
  4. super.initState();
  5. .....
  6. _player = AudioPlayer();
  7. // 监听 AudioPlayer 播放状态,播放完成之后结束播放,重置播放时间
  8. _player.playerStateStream.listen((state) {
  9. switch (state.processingState) {
  10. case ProcessingState.completed:
  11. _player.seek(Duration.zero, index: _player.effectiveIndices.first);
  12. _player.stop();
  13. break;
  14. case ProcessingState.idle:
  15. break;
  16. case ProcessingState.loading:
  17. break;
  18. case ProcessingState.buffering:
  19. break;
  20. case ProcessingState.ready:
  21. break;
  22. }
  23. });
  24. _init();
  25. }
  1. _init() async {
  2. final session = await AudioSession.instance;
  3. await session.configure(AudioSessionConfiguration.music());
  4. try {
  5. var duration = await _player.setAsset('assets/sound/on_duty.mp3');
  6. } catch (e) {
  7. // catch load errors: 404, invalid url ...
  8. print("An error occured $e");
  9. }
  10. }

点击时间播放音频
  1. void _onClick() {
  2. setState(() {
  3. _show = true;
  4. });
  5. if (_player.playing) {
  6. return;
  7. }
  8. Future.delayed(Duration.zero, () {
  9. _animationController.repeat();
  10. Future.delayed(Duration(seconds: 3), () {
  11. setState(() {
  12. _downTime = formatDate(DateTime.now(), [HH, ':', nn, ':', ss]);
  13. _animationController.stop();
  14. _player.play(); // add code
  15. _flipCardKey.currentState.toggleCard();
  16. _show = false;
  17. });
  18. });
  19. });
  20. }

组件销毁生命周期放过释放资源
  1. @override
  2. void dispose() {
  3. if (null != _timer && _timer.isActive) {
  4. _timer.cancel();
  5. }
  6. if (null != _animationController) {
  7. _animationController.dispose();
  8. }
  9. if (null != _player) {
  10. _player.pause();
  11. _player.dispose();
  12. }
  13. super.dispose();
  14. }