Flutter for OpenHarmony高级闹钟App实战:闹钟编辑器实现
闹钟编辑器是整个应用中最复杂的页面,功能特别多。时间选择、重复规则、铃声设置、音量调节、渐进式响铃、解铃挑战…每个功能都需要精心设计。
做这个页面的时候,我最纠结的是怎么把这么多功能组织得清晰易用。最后决定用卡片式布局,把相关的功能分组,每组用一个卡片包裹。这样页面结构清晰,用户也容易找到想要的功能。
页面状态管理
编辑器页面需要管理很多状态,所以用StatefulWidget。
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../../controllers/alarm_controller.dart';
import '../../models/alarm.dart';
class AlarmEditorPage extends StatefulWidget {
final Alarm? alarm;
const AlarmEditorPage({super.key, this.alarm});
State<AlarmEditorPage> createState() => _AlarmEditorPageState();
}
alarm参数:如果传入了alarm,就是编辑模式;如果为null,就是创建模式。这种设计让一个页面同时支持创建和编辑,减少了代码重复。
StatefulWidget的选择:页面有很多表单字段,需要管理它们的状态。虽然也可以用GetX的响应式状态,但对于表单这种场景,StatefulWidget更直观。
class _AlarmEditorPageState extends State<AlarmEditorPage> {
late TimeOfDay _time;
late TextEditingController _labelController;
late List<int> _repeatDays;
late String _ringtoneId;
late double _volume;
late bool _vibrate;
late bool _progressiveRing;
late int _progressiveDuration;
late ChallengeType _challengeType;
late Difficulty _challengeDifficulty;
状态变量:每个可编辑的字段都有对应的状态变量。用late关键字延迟初始化,在initState中根据是创建还是编辑来设置初始值。
TextEditingController:用于管理文本输入框的内容。这是Flutter处理文本输入的标准方式。
void initState() {
super.initState();
if (widget.alarm != null) {
_time = widget.alarm!.time;
_labelController = TextEditingController(text: widget.alarm!.label);
_repeatDays = List.from(widget.alarm!.repeatDays);
_ringtoneId = widget.alarm!.ringtoneId;
_volume = widget.alarm!.volume;
_vibrate = widget.alarm!.vibrate;
_progressiveRing = widget.alarm!.progressiveRing;
_progressiveDuration = widget.alarm!.progressiveDuration;
_challengeType = widget.alarm!.challengeType ?? ChallengeType.none;
_challengeDifficulty = widget.alarm!.challengeDifficulty ?? Difficulty.easy;
编辑模式的初始化:如果是编辑模式,从传入的alarm对象中读取所有字段的值。
List.from的使用:创建repeatDays的副本,而不是直接引用。这样修改时不会影响原对象。
空值处理:challengeType和challengeDifficulty可能为null,用??提供默认值。
} else {
_time = TimeOfDay.now();
_labelController = TextEditingController();
_repeatDays = [];
_ringtoneId = 'gentle_wake';
_volume = 70.0;
_vibrate = true;
_progressiveRing = false;
_progressiveDuration = 5;
_challengeType = ChallengeType.none;
_challengeDifficulty = Difficulty.easy;
}
}
void dispose() {
_labelController.dispose();
super.dispose();
}
创建模式的初始化:设置合理的默认值。比如时间默认为当前时间,音量默认70%,振动默认开启。
资源释放:在dispose中释放TextEditingController,避免内存泄漏。这是Flutter开发的基本规范。
页面布局
编辑器页面用ListView包裹所有内容,可以滚动查看。
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.alarm == null ? '新建闹钟' : '编辑闹钟'),
actions: [
TextButton(
onPressed: _saveAlarm,
child: const Text('保存', style: TextStyle(color: Colors.white)),
),
],
),
AppBar的设计:
- 标题根据模式动态显示"新建闹钟"或"编辑闹钟"
- 右侧放保存按钮,点击后保存并返回
把保存按钮放在AppBar而不是底部,是因为用户编辑完后习惯性地看向顶部。这种设计在很多应用中都能看到。
body: ListView(
padding: EdgeInsets.all(16.w),
children: [
_buildTimePicker(),
SizedBox(height: 16.h),
_buildLabelInput(),
SizedBox(height: 16.h),
_buildRepeatSelector(),
SizedBox(height: 16.h),
_buildRingtoneSelector(),
SizedBox(height: 16.h),
_buildVolumeSlider(),
SizedBox(height: 16.h),
_buildVibrateSwitch(),
SizedBox(height: 16.h),
_buildProgressiveRingSettings(),
SizedBox(height: 16.h),
_buildChallengeSettings(),
],
),
);
}
模块化设计:每个功能都是一个独立的方法,返回一个Widget。这种组织方式让代码结构清晰,易于维护。
间距统一:每个模块之间用16个逻辑像素的间距,保持视觉上的一致性。
时间选择器
时间选择是最重要的功能。
Widget _buildTimePicker() {
return Card(
child: ListTile(
leading: const Icon(Icons.access_time),
title: const Text('时间'),
trailing: Text(
'${_time.hour.toString().padLeft(2, '0')}:${_time.minute.toString().padLeft(2, '0')}',
style: TextStyle(fontSize: 24.sp, fontWeight: FontWeight.bold),
),
onTap: () async {
final time = await showTimePicker(context: context, initialTime: _time);
if (time != null) {
setState(() => _time = time);
}
},
),
);
}
ListTile的布局:
leading放时钟图标title显示"时间"标签trailing显示当前选中的时间,24sp加粗
时间选择器的调用:点击卡片后调用showTimePicker,这是Flutter提供的标准时间选择器。用户选择时间后,用setState更新状态。
异步处理:showTimePicker返回Future,用await等待用户选择。如果用户取消了,返回null,这时不更新状态。
标签输入
标签让用户能给闹钟起个名字。
Widget _buildLabelInput() {
return Card(
child: Padding(
padding: EdgeInsets.all(16.w),
child: TextField(
controller: _labelController,
decoration: const InputDecoration(
labelText: '标签',
hintText: '输入闹钟标签',
border: OutlineInputBorder(),
),
),
),
);
}
TextField的配置:
controller绑定TextEditingControllerlabelText显示字段名称hintText显示占位符提示border使用OutlineInputBorder,有边框的输入框
这种设计很标准,用户一看就知道这是个输入框。
重复规则选择器
重复规则让用户选择闹钟在哪些天响铃。
Widget _buildRepeatSelector() {
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
return Card(
child: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('重复', style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 12.h),
weekDays数组:定义星期的中文名称,索引0-6对应周日到周六。这个顺序和repeatDays的格式一致。
Column布局:标题和选择器垂直排列,crossAxisAlignment.start让内容左对齐。
Wrap(
spacing: 8.w,
children: List.generate(7, (index) {
final isSelected = _repeatDays.contains(index);
return FilterChip(
label: Text(weekDays[index]),
selected: isSelected,
onSelected: (selected) {
setState(() {
if (selected) {
_repeatDays.add(index);
} else {
_repeatDays.remove(index);
}
});
},
);
}),
),
],
),
),
);
}
FilterChip的使用:这是Material Design提供的筛选芯片组件,非常适合多选场景。
List.generate:生成7个FilterChip,每个对应一天。isSelected判断当前天是否被选中。
选择逻辑:点击芯片时,如果是选中状态就添加到列表,如果是取消状态就从列表移除。用setState更新UI。
这种交互方式很直观,用户一看就懂。选中的芯片会高亮显示,视觉反馈很明确。
铃声选择器
铃声选择器目前是个占位实现,点击后显示提示。
Widget _buildRingtoneSelector() {
return Card(
child: ListTile(
leading: const Icon(Icons.music_note),
title: const Text('铃声'),
subtitle: Text(_ringtoneId),
trailing: const Icon(Icons.chevron_right),
onTap: () {
// TODO: 实现铃声选择器
Get.snackbar('提示', '铃声选择功能待实现');
},
),
);
}
ListTile的布局:
leading放音乐图标title显示"铃声"标签subtitle显示当前选中的铃声IDtrailing放右箭头,暗示可以点击
TODO注释:铃声选择器需要单独实现,这里先用Snackbar占位。实际项目中,应该跳转到铃声选择页面,让用户试听并选择铃声。
音量滑块
音量调节用Slider组件实现。
Widget _buildVolumeSlider() {
return Card(
child: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('音量: ${_volume.toInt()}%', style: const TextStyle(fontWeight: FontWeight.bold)),
Slider(
value: _volume,
min: 0,
max: 100,
divisions: 20,
onChanged: (value) => setState(() => _volume = value),
),
],
),
),
);
}
Slider的配置:
value当前值min和max定义范围0-100divisions设置为20,表示有20个刻度,每个刻度5%onChanged回调更新状态
实时显示:标题显示当前音量百分比,用toInt()转换为整数。用户拖动滑块时,数字会实时更新。
这种设计让用户能精确控制音量,同时也能看到当前值。
振动开关
振动功能用SwitchListTile实现。
Widget _buildVibrateSwitch() {
return Card(
child: SwitchListTile(
secondary: const Icon(Icons.vibration),
title: const Text('振动'),
value: _vibrate,
onChanged: (value) => setState(() => _vibrate = value),
),
);
}
SwitchListTile:这是ListTile和Switch的组合组件,专门用于开关设置。
secondary参数:放在左侧的图标,这里用振动图标。
交互简单:点击开关或整个卡片都能切换状态,用户体验很好。
渐进式响铃设置
渐进式响铃是个高级功能,需要额外的配置。
Widget _buildProgressiveRingSettings() {
return Card(
child: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('渐进式响铃'),
value: _progressiveRing,
onChanged: (value) => setState(() => _progressiveRing = value),
),
主开关:用SwitchListTile实现,contentPadding设置为zero去掉默认内边距,因为外层Card已经有padding了。
条件显示:只有开启渐进式响铃后,才显示时长设置。这种渐进式披露的设计能避免界面过于复杂。
if (_progressiveRing) ...[
SizedBox(height: 8.h),
Text('渐进时长: $_progressiveDuration 分钟'),
Slider(
value: _progressiveDuration.toDouble(),
min: 1,
max: 10,
divisions: 9,
onChanged: (value) => setState(() => _progressiveDuration = value.toInt()),
),
],
],
),
),
);
}
时长滑块:范围1-10分钟,9个刻度。用户可以选择渐进式响铃的时长。
if语句的使用:Dart的语法糖,可以在列表中条件性地添加Widget。...扩展运算符将列表展开。
这种设计让高级功能不会干扰基础功能,用户可以根据需要选择是否使用。
解铃挑战设置
解铃挑战是DeepWake的特色功能。
Widget _buildChallengeSettings() {
return Card(
child: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('解铃挑战', style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 12.h),
DropdownButtonFormField<ChallengeType>(
value: _challengeType,
decoration: const InputDecoration(
labelText: '挑战类型',
border: OutlineInputBorder(),
),
DropdownButtonFormField:下拉选择框,用于选择挑战类型。
泛型参数:<ChallengeType>指定选项的类型,这样编译时就能检查类型错误。
decoration:配置输入框的外观,使用OutlineInputBorder保持与其他输入框一致。
items: const [
DropdownMenuItem(value: ChallengeType.none, child: Text('无')),
DropdownMenuItem(value: ChallengeType.math, child: Text('数学题')),
DropdownMenuItem(value: ChallengeType.shake, child: Text('摇晃手机')),
],
onChanged: (value) => setState(() => _challengeType = value!),
),
选项定义:三个选项对应三种挑战类型。DropdownMenuItem的value是枚举值,child是显示的文字。
onChanged回调:用户选择后更新状态。value!用非空断言,因为我们知道value不会为null。
if (_challengeType != ChallengeType.none) ...[
SizedBox(height: 12.h),
DropdownButtonFormField<Difficulty>(
value: _challengeDifficulty,
decoration: const InputDecoration(
labelText: '难度',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: Difficulty.easy, child: Text('简单')),
DropdownMenuItem(value: Difficulty.medium, child: Text('中等')),
DropdownMenuItem(value: Difficulty.hard, child: Text('困难')),
],
onChanged: (value) => setState(() => _challengeDifficulty = value!),
),
],
],
),
),
);
}
难度选择:只有选择了挑战类型(不是"无")后,才显示难度选择。这又是一个渐进式披露的例子。
三个难度等级:简单、中等、困难。不同难度对应不同的挑战参数,比如数学题的复杂度,摇晃的次数等。
保存逻辑
保存按钮点击后,需要创建或更新闹钟。
void _saveAlarm() {
final controller = Get.find<AlarmController>();
final alarm = widget.alarm?.copyWith(
label: _labelController.text,
time: _time,
repeatDays: _repeatDays,
ringtoneId: _ringtoneId,
volume: _volume,
vibrate: _vibrate,
progressiveRing: _progressiveRing,
progressiveDuration: _progressiveDuration,
challengeType: _challengeType,
challengeDifficulty: _challengeDifficulty,
) ??
编辑模式:如果widget.alarm不为null,用copyWith创建一个更新了字段的新对象。
空值合并:用??运算符,如果是编辑模式就用copyWith的结果,如果是创建模式就用Alarm.create。
Alarm.create(
label: _labelController.text,
time: _time,
repeatDays: _repeatDays,
ringtoneId: _ringtoneId,
volume: _volume,
vibrate: _vibrate,
progressiveRing: _progressiveRing,
progressiveDuration: _progressiveDuration,
challengeType: _challengeType,
challengeDifficulty: _challengeDifficulty,
);
if (widget.alarm == null) {
controller.createAlarm(alarm);
} else {
controller.updateAlarm(alarm);
}
Get.back();
Get.snackbar('成功', '闹钟已保存');
}
}
创建模式:用Alarm.create工厂方法创建新闹钟,它会自动生成ID和创建时间。
调用控制器:根据模式调用createAlarm或updateAlarm。
返回和提示:Get.back()返回上一页,Get.snackbar显示成功提示。
表单验证的考虑
当前实现没有做表单验证,实际项目中应该加上。
可以验证的内容:
- 标签长度不能太长
- 重复规则至少选一天(如果不是一次性闹钟)
- 音量不能为0(除非用户真的想静音)
验证时机:可以在保存时验证,也可以实时验证。实时验证用户体验更好,但实现更复杂。
错误提示:验证失败时,应该用Snackbar或Dialog提示用户,并阻止保存。
总结
闹钟编辑器是个功能丰富的页面,实现起来需要考虑很多细节。从表单设计到状态管理,从交互逻辑到数据保存,每个环节都很重要。
几个关键点:
- 用StatefulWidget管理表单状态
- 用卡片式布局组织功能模块
- 用渐进式披露避免界面过于复杂
- 用标准组件(Slider、Switch、Dropdown)提升用户体验
- 一个页面同时支持创建和编辑,减少代码重复
下一篇咱们会讲响铃界面的实现,那个页面涉及到动画和传感器,会很有意思。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐

所有评论(0)