闹钟编辑器是整个应用中最复杂的页面,功能特别多。时间选择、重复规则、铃声设置、音量调节、渐进式响铃、解铃挑战…每个功能都需要精心设计。

做这个页面的时候,我最纠结的是怎么把这么多功能组织得清晰易用。最后决定用卡片式布局,把相关的功能分组,每组用一个卡片包裹。这样页面结构清晰,用户也容易找到想要的功能。
请添加图片描述

页面状态管理

编辑器页面需要管理很多状态,所以用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的副本,而不是直接引用。这样修改时不会影响原对象。

空值处理challengeTypechallengeDifficulty可能为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绑定TextEditingController
  • labelText显示字段名称
  • 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显示当前选中的铃声ID
  • trailing放右箭头,暗示可以点击

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当前值
  • minmax定义范围0-100
  • divisions设置为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和创建时间。

调用控制器:根据模式调用createAlarmupdateAlarm

返回和提示Get.back()返回上一页,Get.snackbar显示成功提示。

表单验证的考虑

当前实现没有做表单验证,实际项目中应该加上。

可以验证的内容

  • 标签长度不能太长
  • 重复规则至少选一天(如果不是一次性闹钟)
  • 音量不能为0(除非用户真的想静音)

验证时机:可以在保存时验证,也可以实时验证。实时验证用户体验更好,但实现更复杂。

错误提示:验证失败时,应该用Snackbar或Dialog提示用户,并阻止保存。

总结

闹钟编辑器是个功能丰富的页面,实现起来需要考虑很多细节。从表单设计到状态管理,从交互逻辑到数据保存,每个环节都很重要。

几个关键点

  • 用StatefulWidget管理表单状态
  • 用卡片式布局组织功能模块
  • 用渐进式披露避免界面过于复杂
  • 用标准组件(Slider、Switch、Dropdown)提升用户体验
  • 一个页面同时支持创建和编辑,减少代码重复

下一篇咱们会讲响铃界面的实现,那个页面涉及到动画和传感器,会很有意思。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐