Flutter for OpenHarmony轻量级开源记事本App实战:笔记编辑器

笔记编辑器是应用的核心功能,用户在这里创作和编辑内容。一个好的编辑器应该提供流畅的输入体验、丰富的编辑功能和可靠的保存机制。本文将详细介绍如何实现一个功能完善的笔记编辑器。

编辑器页面的基础结构

编辑器页面使用StatefulWidget管理编辑状态和撤销重做栈。

dart 复制代码
class NoteEditorPage extends StatefulWidget {
  final Note note;

  const NoteEditorPage({super.key, required this.note});

  @override
  State<NoteEditorPage> createState() => _NoteEditorPageState();
}

这是编辑器页面的入口类,采用StatefulWidget设计以支持动态状态管理。页面接收一个Note对象作为必需参数,这个对象包含了要编辑的笔记的所有信息。通过createState方法创建对应的状态类实例,将页面的UI渲染和状态管理分离,这是Flutter推荐的标准做法。这种设计让页面可以响应用户的各种编辑操作,实时更新界面显示。

dart 复制代码
class _NoteEditorPageState extends State<NoteEditorPage> {
  final NoteController _controller = Get.find();
  late TextEditingController _titleController;
  late TextEditingController _contentController;
  late Note _note;
  bool _hasChanges = false;
  final List<String> _undoStack = [];
  final List<String> _redoStack = [];

状态类中定义了编辑器所需的核心变量。_controller通过GetX依赖注入获取,用于调用笔记的增删改查操作。两个TextEditingController分别管理标题和内容的输入框,它们是Flutter中处理文本输入的标准方式。_note变量存储当前正在编辑的笔记对象副本,_hasChanges标志位用于追踪是否有未保存的修改。_undoStack和_redoStack两个字符串列表实现了撤销重做功能,这是文本编辑器的重要特性。

dart 复制代码
  @override
  void initState() {
    super.initState();
    _note = widget.note;
    _titleController = TextEditingController(text: _note.title);
    _contentController = TextEditingController(text: _note.content);
    _undoStack.add(_note.content);
    _titleController.addListener(_onChanged);
    _contentController.addListener(_onContentChanged);
  }

initState方法在状态对象创建时调用,用于初始化各种资源。首先将传入的笔记对象赋值给_note,然后创建两个TextEditingController并用笔记的标题和内容初始化。将初始内容添加到撤销栈,这样用户可以撤销到最初状态。最后为两个控制器添加监听器,当文本发生变化时会自动触发回调函数。这种监听机制是实现自动保存提示和撤销重做的基础。

dart 复制代码
  void _onChanged() {
    if (!_hasChanges) setState(() => _hasChanges = true);
  }

  void _onContentChanged() {
    _onChanged();
    if (_undoStack.isEmpty || _undoStack.last != _contentController.text) {
      _undoStack.add(_contentController.text);
      _redoStack.clear();
    }
  }

_onChanged是通用的变化处理方法,当检测到内容修改时将_hasChanges标志设为true,触发setState重新渲染界面。_onContentChanged专门处理内容输入框的变化,它首先调用_onChanged标记修改,然后检查撤销栈是否为空或当前文本是否与栈顶不同。如果条件满足,将新文本压入撤销栈,并清空重做栈。清空重做栈是因为用户进行了新的编辑操作,之前的重做历史已经失效。这种设计确保了撤销重做功能的正确性。

编辑器的AppBar设计

AppBar提供保存、撤销重做和更多选项等功能入口。

dart 复制代码
  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: _onWillPop,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('编辑笔记'),
          actions: [
            IconButton(
              icon: const Icon(Icons.undo),
              onPressed: _undoStack.length > 1 ? _undo : null,
            ),

build方法构建整个编辑器界面。最外层使用WillPopScope包裹,这是一个特殊的Widget,可以拦截用户的返回操作(如按返回键或滑动返回),通过onWillPop回调处理未保存的修改。Scaffold提供了Material Design的基本页面结构,AppBar显示页面标题和操作按钮。撤销按钮使用条件表达式动态设置onPressed,只有当撤销栈中有多于一个项目时才启用,否则设为null自动禁用按钮。这种动态启用禁用的设计让用户清楚知道哪些操作当前可用。

dart 复制代码
            IconButton(
              icon: const Icon(Icons.redo),
              onPressed: _redoStack.isNotEmpty ? _redo : null,
            ),
            IconButton(
              icon: const Icon(Icons.more_vert),
              onPressed: () => _showMoreOptions(context),
            ),
            IconButton(
              icon: const Icon(Icons.check),
              onPressed: _saveNote,
            ),
          ],
        ),

重做按钮的启用逻辑与撤销按钮类似,只在重做栈不为空时可用。更多选项按钮使用竖向三点图标,点击后调用_showMoreOptions方法显示底部菜单,提供标签、颜色、文件夹等高级设置。保存按钮使用勾选图标,这是一个通用的确认符号,点击后调用_saveNote方法保存笔记。这些按钮从左到右按使用频率排列,最常用的保存按钮放在最右侧,符合右手操作习惯,提升用户体验。

编辑器的主体布局

编辑器主体包含标题输入框和内容输入框,支持自由输入。

dart 复制代码
        body: Column(
          children: [
            Expanded(
              child: SingleChildScrollView(
                padding: EdgeInsets.all(16.w),
                child: Column(
                  children: [
                    TextField(
                      controller: _titleController,
                      style: TextStyle(
                        fontSize: 20.sp,

body部分使用Column垂直布局,包含可滚动的内容区域和底部信息栏。Expanded让SingleChildScrollView占据除底部栏外的所有剩余空间,确保内容区域可以充分利用屏幕。SingleChildScrollView提供滚动功能,当内容超出屏幕时用户可以滚动查看。padding使用ScreenUtil的响应式单位设置16个逻辑像素的内边距,在不同屏幕尺寸上保持一致的视觉效果。标题输入框使用较大的字号20.sp,这个尺寸在手机上清晰可读又不会过大。

dart 复制代码
                        fontWeight: FontWeight.bold,
                      ),
                      decoration: const InputDecoration(
                        hintText: '标题',
                        border: InputBorder.none,
                      ),
                    ),
                    TextField(
                      controller: _contentController,
                      style: TextStyle(fontSize: _controller.fontSize.value.sp),
                      maxLines: null,

标题使用粗体字重,让其在视觉上与内容区分开来。InputDecoration的hintText显示提示文字"标题",当输入框为空时引导用户输入。border设置为none去除默认的下划线边框,营造简洁的写作环境。内容输入框的字体大小从控制器的fontSize属性获取,这是用户在设置中自定义的字号,体现了应用的个性化特性。maxLines设置为null表示不限制行数,输入框会随着内容自动增长。

dart 复制代码
                      minLines: 20,
                      decoration: const InputDecoration(
                        hintText: '开始写作...',
                        border: InputBorder.none,
                      ),
                    ),
                  ],
                ),
              ),
            ),
            _buildBottomBar(),
          ],
        ),

minLines设置为20提供足够的初始高度,让用户打开编辑器时就能看到宽敞的写作空间,不会感到局促。hintText使用"开始写作..."这样友好的提示语,鼓励用户开始创作。同样去除边框保持界面简洁。_buildBottomBar()方法构建底部信息栏,显示字数统计和快捷操作按钮。这种上下分离的布局让编辑区域和功能区域各司其职,用户可以专注于写作,需要时再使用底部的功能。

底部信息栏的实现

底部信息栏显示字数统计和快捷操作按钮。

dart 复制代码
  Widget _buildBottomBar() {
    return Container(
      padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
      decoration: BoxDecoration(
        color: Theme.of(context).cardColor,
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.05),
            blurRadius: 4,
            offset: const Offset(0, -2),
          ),

_buildBottomBar方法构建底部信息栏Widget。Container提供了padding和decoration属性,padding使用symmetric设置水平和垂直方向的内边距,让内容不会紧贴边缘。BoxDecoration定义了容器的视觉样式,color使用主题的cardColor保持与应用整体风格一致。boxShadow添加了一个向上的阴影效果,通过offset设置为(0, -2)让阴影出现在容器上方,blurRadius控制模糊程度,opacity设置为0.05创造出微妙的分层效果,让底部栏在视觉上与内容区域分离。

dart 复制代码
        ],
      ),
      child: Row(
        children: [
          Text(
            '${_contentController.text.length} 字符',
            style: TextStyle(fontSize: 12.sp, color: Colors.grey),
          ),
          SizedBox(width: 16.w),
          Text(
            '${_note.wordCount} 词',
            style: TextStyle(fontSize: 12.sp, color: Colors.grey),
          ),

Row布局将所有子元素水平排列。第一个Text显示字符数统计,使用字符串插值实时获取_contentController的文本长度。字体大小设置为12.sp,这是一个较小的尺寸,适合辅助信息显示。颜色使用灰色,让统计信息不会过于突出,保持低调。SizedBox提供16个逻辑像素的水平间距,分隔两个统计项。第二个Text显示词数统计,从_note对象的wordCount属性获取,这个值在Note模型中计算得出。两个统计项使用相同的样式保持视觉一致性。

dart 复制代码
          const Spacer(),
          IconButton(
            icon: Icon(
              _note.isPinned ? Icons.push_pin : Icons.push_pin_outlined,
              color: _note.isPinned ? const Color(0xFF2196F3) : null,
            ),
            onPressed: () {
              setState(() {
                _note = _note.copyWith(isPinned: !_note.isPinned);
                _hasChanges = true;
              });
            },
          ),

Spacer是一个特殊的Widget,它会占据Row中所有剩余的空间,将后面的按钮推到最右边,实现左右分布的布局效果。置顶按钮根据_note.isPinned状态显示不同的图标,已置顶时显示实心图钉,未置顶时显示空心图钉,这种视觉反馈让用户一眼就能看出当前状态。color属性在已置顶时设置为蓝色(0xFF2196F3)进行高亮显示,未置顶时为null使用默认颜色。点击按钮时调用setState更新状态,使用copyWith方法创建新的Note对象并切换isPinned值,同时将_hasChanges设为true标记有修改。

dart 复制代码
          IconButton(
            icon: Icon(
              _note.isFavorite ? Icons.star : Icons.star_outline,
              color: _note.isFavorite ? Colors.amber : null,
            ),
            onPressed: () {
              setState(() {
                _note = _note.copyWith(isFavorite: !_note.isFavorite);
                _hasChanges = true;
              });
            },
          ),
        ],
      ),
    );
  }

收藏按钮的实现逻辑与置顶按钮完全相同,只是使用了不同的图标和颜色。已收藏时显示实心星标并用琥珀色(Colors.amber)高亮,未收藏时显示空心星标。点击时同样使用copyWith切换isFavorite状态。这两个快捷按钮让用户无需打开更多选项菜单就能完成常用操作,大大提升了使用效率。将这些高频操作放在底部栏而不是AppBar,是因为底部更容易用拇指触及,符合移动设备的操作习惯。

保存笔记的实现

保存笔记时需要更新笔记对象并调用控制器保存。

dart 复制代码
  void _saveNote() {
    _note = _note.copyWith(
      title: _titleController.text,
      content: _contentController.text,
    );
    _controller.updateNote(_note);
    setState(() => _hasChanges = false);
    Get.snackbar('提示', '保存成功', snackPosition: SnackPosition.BOTTOM);
  }

_saveNote方法负责将用户的编辑保存到数据库。首先使用copyWith方法创建一个新的Note对象,将当前输入框中的标题和内容更新到笔记对象中。copyWith是Dart中常用的不可变对象更新模式,它创建一个新对象而不是修改原对象,这种方式更安全且便于状态管理。然后调用控制器的updateNote方法将笔记保存到数据库,控制器会处理所有的数据持久化逻辑。接着调用setState将_hasChanges重置为false,表示当前没有未保存的修改。最后使用GetX的snackbar显示"保存成功"提示,snackPosition设置为BOTTOM让提示从底部弹出,这种明确的反馈让用户确信操作已完成。

返回时的保存提示

用户返回时,如果有未保存的修改需要提示确认。

dart 复制代码
  Future<bool> _onWillPop() async {
    if (!_hasChanges) {
      Get.back(result: _note.title.isNotEmpty || _note.content.isNotEmpty);
      return false;
    }
    
    final result = await showDialog<int>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('保存更改?'),
        content: const Text('您有未保存的更改'),

_onWillPop是WillPopScope的回调方法,在用户尝试返回时被调用。返回值为bool类型,true表示允许返回,false表示阻止返回。方法首先检查_hasChanges标志,如果没有修改则直接调用Get.back返回上一页,result参数传递一个布尔值表示笔记是否有内容,这个信息可能被上一页用于更新列表。如果有未保存的修改,使用showDialog显示一个AlertDialog对话框,泛型参数表示对话框返回整数类型的结果。对话框的title和content清楚地告知用户当前的情况。

dart 复制代码
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, 0),
            child: const Text('放弃'),
          ),
          TextButton(
            onPressed: () => Navigator.pop(context, 1),
            child: const Text('取消'),
          ),
          ElevatedButton(
            onPressed: () => Navigator.pop(context, 2),
            child: const Text('保存'),
          ),
        ],
      ),
    );

对话框提供三个操作选项,这是处理未保存修改的标准模式。"放弃"按钮返回0,表示用户选择丢弃修改直接退出。"取消"按钮返回1,表示用户改变主意,继续编辑。"保存"按钮返回2,表示用户要保存修改后退出。使用ElevatedButton突出显示"保存"选项,因为这通常是用户的首选操作。每个按钮点击时调用Navigator.pop关闭对话框并返回对应的数字。这种三选项设计给用户充分的控制权,避免意外丢失数据。

dart 复制代码
    if (result == 0) {
      Get.back(result: false);
    } else if (result == 2) {
      _saveNote();
      Get.back(result: true);
    }
    return false;
  }

await等待用户在对话框中做出选择后,根据返回值执行相应操作。如果result为0(放弃),直接调用Get.back返回上一页,result参数为false表示笔记未保存。如果result为2(保存),先调用_saveNote保存笔记,然后返回上一页,result参数为true表示笔记已保存。如果result为1(取消)或null(点击对话框外部关闭),不执行任何操作。最后返回false阻止WillPopScope的默认返回行为,因为我们已经通过Get.back手动控制了页面跳转。这种精细的控制确保用户不会意外丢失未保存的内容。

撤销重做功能的实现

撤销重做功能让用户可以回退或恢复编辑操作。

dart 复制代码
  void _undo() {
    if (_undoStack.length > 1) {
      _redoStack.add(_undoStack.removeLast());
      _contentController.text = _undoStack.last;
    }
  }

  void _redo() {
    if (_redoStack.isNotEmpty) {
      final text = _redoStack.removeLast();
      _undoStack.add(text);
      _contentController.text = text;
    }
  }

撤销重做是文本编辑器的重要功能,使用栈数据结构实现。_undo方法首先检查撤销栈是否有多于一个项目(至少保留初始状态),然后从撤销栈弹出当前状态并压入重做栈,最后将内容控制器的文本设置为撤销栈的栈顶(上一个状态)。_redo方法检查重做栈是否不为空,从重做栈弹出一个状态,将其压入撤销栈,并设置为当前文本。这种栈结构是实现撤销重做的经典算法,简单高效且易于理解。每次文本变化时都会自动压入撤销栈,用户可以随时回退到任何历史状态。

更多选项菜单

更多选项菜单提供标签、颜色、文件夹等高级设置。

dart 复制代码
  void _showMoreOptions(BuildContext context) {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      builder: (context) => DraggableScrollableSheet(
        initialChildSize: 0.6,
        minChildSize: 0.4,
        maxChildSize: 0.9,
        expand: false,
        builder: (context, scrollController) => Container(

_showMoreOptions方法显示一个底部弹出菜单,包含笔记的各种高级设置选项。使用showModalBottomSheet创建模态底部表单,isScrollControlled设为true允许自定义高度。DraggableScrollableSheet是一个可拖动调整大小的滚动表单,initialChildSize设为0.6表示初始高度为屏幕的60%,minChildSize和maxChildSize定义了可拖动的范围,用户可以向上拖动查看更多内容或向下拖动缩小。expand设为false表示不自动展开到最大高度。这种可拖动的设计让用户可以根据需要调整菜单大小,提供了灵活的交互体验。

dart 复制代码
          decoration: BoxDecoration(
            color: Theme.of(context).scaffoldBackgroundColor,
            borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
          ),
          child: ListView(
            controller: scrollController,
            padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom),
            children: [
            ListTile(
              leading: const Icon(Icons.label_outline),
              title: const Text('标签'),

Container的decoration设置了背景色和圆角,使用主题的scaffoldBackgroundColor保持一致性,顶部圆角半径为16创造出从底部滑出的视觉效果。ListView使用传入的scrollController实现滚动功能,padding.bottom使用MediaQuery获取系统底部安全区域高度,确保内容不会被系统导航栏遮挡。第一个ListTile是标签选项,leading显示标签图标,title显示"标签"文字。ListTile是Material Design中标准的列表项组件,提供了统一的布局和交互效果。

dart 复制代码
              trailing: Text(_note.tags.isEmpty ? '无' : _note.tags.join(', ')),
              onTap: () {
                Navigator.pop(context);
                _showTagSelector();
              },
            ),
            ListTile(
              leading: const Icon(Icons.palette_outlined),
              title: const Text('背景颜色'),
              onTap: () {
                Navigator.pop(context);
                _showColorPicker();
              },
            ),

trailing显示当前标签列表,如果没有标签显示"无",否则用逗号连接所有标签。这种预览设计让用户无需打开选择器就能看到当前设置。onTap回调中先关闭底部菜单,然后打开标签选择器,这种分步操作避免了多个弹窗叠加。背景颜色选项使用调色板图标,点击后打开颜色选择器。每个选项都有清晰的图标和文字说明,让用户容易理解其功能。

dart 复制代码
            ListTile(
              leading: const Icon(Icons.folder_outlined),
              title: const Text('移动到文件夹'),
              onTap: () {
                Navigator.pop(context);
                _showFolderSelector();
              },
            ),
            ListTile(
              leading: const Icon(Icons.category_outlined),
              title: const Text('设置分类'),
              onTap: () {
                Navigator.pop(context);
                _showCategorySelector();
              },
            ),

文件夹选项让用户将笔记移动到指定文件夹进行组织管理。分类选项让用户为笔记设置分类标签,分类通常有颜色标识,便于视觉区分。这两个功能都是笔记管理的重要手段,文件夹提供了层级结构,分类提供了标签系统,两者结合可以构建灵活的组织方式。每个选项的交互模式都相同:关闭菜单,打开对应的选择器,这种一致性降低了学习成本。

dart 复制代码
            ListTile(
              leading: Icon(_note.isLocked ? Icons.lock_open : Icons.lock),
              title: Text(_note.isLocked ? '解锁笔记' : '锁定笔记'),
              onTap: () {
                Navigator.pop(context);
                _toggleLock();
              },
            ),
            ListTile(
              leading: const Icon(Icons.share_outlined),
              title: const Text('分享(复制到剪贴板)'),
              onTap: () async {
                Navigator.pop(context);
                final text = StringBuffer()

锁定选项根据当前状态显示不同的图标和文字,已锁定显示开锁图标和"解锁笔记",未锁定显示锁图标和"锁定笔记",这种动态文本让用户清楚知道点击后的效果。分享选项使用async标记为异步方法,因为需要执行剪贴板操作。StringBuffer用于高效地构建多行文本,比字符串拼接性能更好。这个选项实现了简单的分享功能,将笔记内容复制到剪贴板,用户可以粘贴到其他应用。

dart 复制代码
                  ..writeln(_note.title.isEmpty ? '无标题' : _note.title)
                  ..writeln('')
                  ..writeln(_contentController.text);
                await Clipboard.setData(ClipboardData(text: text.toString()));
                Get.snackbar('提示', '已复制到剪贴板', snackPosition: SnackPosition.BOTTOM);
              },
            ),
            ListTile(
              leading: const Icon(Icons.notifications_outlined),
              title: const Text('设置提醒时间'),

使用级联操作符(...)连续调用writeln方法,先写入标题(如果为空则写"无标题"),然后写入空行,最后写入内容。这种格式化让分享的文本结构清晰。Clipboard.setData将文本设置到系统剪贴板,await等待操作完成。完成后显示提示消息告知用户。提醒时间选项让用户为笔记设置定时提醒,这是笔记应用的实用功能,可以用于待办事项或重要事件的提醒。

dart 复制代码
              trailing: Text(_note.reminderTime == null
                  ? '未设置'
                  : '${_note.reminderTime!.year}-${_note.reminderTime!.month.toString().padLeft(2, '0')}-${_note.reminderTime!.day.toString().padLeft(2, '0')} ${_note.reminderTime!.hour.toString().padLeft(2, '0')}:${_note.reminderTime!.minute.toString().padLeft(2, '0')}'),
              onTap: () async {
                Navigator.pop(context);
                await _pickReminderTime();
              },
            ),

trailing显示当前的提醒时间,如果未设置显示"未设置",否则格式化显示日期时间。使用padLeft方法确保月、日、时、分都是两位数,例如"2024-01-05 09:30"而不是"2024-1-5 9:30",这种格式更规范易读。感叹号(!)是Dart的非空断言操作符,因为前面已经判断reminderTime不为null。点击后调用_pickReminderTime方法打开日期时间选择器。

dart 复制代码
            if (_note.reminderTime != null)
              ListTile(
                leading: const Icon(Icons.notifications_off_outlined),
                title: const Text('清除提醒'),
                onTap: () {
                  Navigator.pop(context);
                  setState(() {
                    _note = _note.copyWith(reminderTime: null);
                    _hasChanges = true;
                  });
                  _controller.updateNote(_note);
                  Get.snackbar('提示', '已清除提醒', snackPosition: SnackPosition.BOTTOM);
                },
              ),

这个ListTile使用if条件包裹,只在reminderTime不为null时显示,即只有设置了提醒才显示清除选项。这种条件渲染让界面更简洁,避免显示无意义的选项。点击后将reminderTime设为null清除提醒,调用updateNote立即保存到数据库,并显示提示消息。这种即时保存的设计让用户不用担心忘记保存设置。

dart 复制代码
            ListTile(
              leading: const Icon(Icons.copy),
              title: const Text('复制笔记'),
              onTap: () {
                Navigator.pop(context);
                _controller.duplicateNote(_note.id);
                Get.snackbar('提示', '已创建副本', snackPosition: SnackPosition.BOTTOM);
              },
            ),
            ListTile(
              leading: const Icon(Icons.delete_outline, color: Colors.red),
              title: const Text('删除', style: TextStyle(color: Colors.red)),
              onTap: () {
                Navigator.pop(context);
                _controller.deleteNote(_note.id);
                Get.back();
                Get.snackbar('提示', '已移至回收站', snackPosition: SnackPosition.BOTTOM);
              },
            ),

复制笔记选项调用控制器的duplicateNote方法创建笔记的副本,这个功能让用户可以基于现有笔记快速创建新笔记,避免重复输入相似内容。删除选项使用红色图标和文字进行警示,这是危险操作的标准视觉设计。点击后调用deleteNote删除笔记(实际是移至回收站),然后调用Get.back返回上一页,因为当前笔记已被删除,继续停留在编辑页面没有意义。这些操作选项覆盖了笔记管理的主要需求,让用户可以方便地管理笔记。

提醒时间选择器

提醒时间选择器让用户为笔记设置定时提醒。

dart 复制代码
  Future<void> _pickReminderTime() async {
    if (!_controller.remindersEnabled.value) {
      Get.snackbar('提示', '提醒已在通知设置中关闭', snackPosition: SnackPosition.BOTTOM);
      return;
    }

    final now = DateTime.now();
    final initial = _note.reminderTime ?? now.add(Duration(minutes: _controller.defaultReminderMinutes.value));

_pickReminderTime方法处理提醒时间的选择。首先检查控制器的remindersEnabled标志,如果用户在设置中关闭了提醒功能,显示提示消息并直接返回,避免用户设置了无效的提醒。获取当前时间作为参考点,计算初始时间:如果笔记已有提醒时间则使用它,否则使用当前时间加上默认提醒分钟数(从设置中获取)。这种智能默认值让用户不用每次都从头选择时间。

dart 复制代码
    final date = await showDatePicker(
      context: context,
      initialDate: initial,
      firstDate: now.subtract(const Duration(days: 365 * 5)),
      lastDate: now.add(const Duration(days: 365 * 5)),
    );
    if (date == null) return;

    final time = await showTimePicker(
      context: context,
      initialTime: TimeOfDay.fromDateTime(initial),
    );
    if (time == null) return;

showDatePicker显示日期选择器,initialDate设置初始选中的日期,firstDate和lastDate定义可选择的日期范围(前后各5年)。await等待用户选择,如果用户取消(返回null)则直接返回。showTimePicker显示时间选择器,initialTime从initial转换为TimeOfDay类型。同样等待用户选择,取消则返回。这种分两步选择日期和时间的方式是移动应用的标准做法,比一次性选择更清晰。

dart 复制代码
    final selected = DateTime(date.year, date.month, date.day, time.hour, time.minute);
    setState(() {
      _note = _note.copyWith(reminderTime: selected);
      _hasChanges = true;
    });
    _controller.updateNote(_note);
    Get.snackbar('提示', '提醒时间已设置', snackPosition: SnackPosition.BOTTOM);
  }

将用户选择的日期和时间组合成一个DateTime对象,使用date的年月日和time的时分。调用setState更新笔记对象的reminderTime属性并标记有修改。立即调用updateNote保存到数据库,确保提醒设置不会丢失。显示提示消息告知用户设置成功。这种即时保存的设计让用户不用担心忘记保存,提升了可靠性。

标签选择器的调用

标签选择器让用户为笔记添加或删除标签。

dart 复制代码
  void _showTagSelector() async {
    final result = await showDialog<List<String>>(
      context: context,
      builder: (context) => TagSelector(selectedTags: _note.tags),
    );
    if (result != null) {
      setState(() {
        _note = _note.copyWith(tags: result);
        _hasChanges = true;
      });
    }
  }

_showTagSelector方法显示标签选择器对话框。使用showDialog创建对话框,泛型参数<List>表示对话框返回字符串列表类型的结果。builder返回TagSelector组件,这是一个自定义的标签选择器Widget,传入当前选中的标签列表。await等待用户完成选择,如果用户确认选择(result不为null),调用setState更新笔记的tags属性并标记有修改。如果用户取消(result为null),不执行任何操作,保持原有标签不变。这种异步处理模式让代码逻辑清晰,避免了回调嵌套。

颜色选择器的调用

颜色选择器让用户为笔记设置背景颜色。

dart 复制代码
  void _showColorPicker() async {
    final result = await showDialog<String?>(
      context: context,
      builder: (context) => NoteColorPicker(currentColor: _note.color),
    );
    if (result != null) {
      setState(() {
        _note = _note.copyWith(color: result == 'none' ? null : result);
        _hasChanges = true;
      });
    }
  }

_showColorPicker方法显示颜色选择器对话框。泛型参数<String?>表示返回可空的字符串类型,因为颜色可以为null。builder返回NoteColorPicker组件,这是一个自定义的颜色选择器Widget,传入当前的颜色值。await等待用户选择颜色,如果用户确认选择(result不为null),使用copyWith更新笔记的color属性。特殊处理'none'值,将其转换为null表示清除颜色。这种设计让用户既可以设置颜色,也可以清除颜色恢复默认样式。同时标记_hasChanges为true,确保修改会被保存提示捕获。

文件夹选择器的实现

文件夹选择器让用户将笔记移动到指定文件夹。

dart 复制代码
  void _showFolderSelector() {
    final folders = _controller.folders;
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('选择文件夹'),
        content: SizedBox(
          width: double.maxFinite,
          child: ListView(
            shrinkWrap: true,

_showFolderSelector方法显示文件夹选择对话框。首先从控制器获取所有文件夹列表,然后使用AlertDialog创建对话框。title显示"选择文件夹"标题,content使用SizedBox包裹ListView,width设为double.maxFinite让对话框尽可能宽,提供更好的显示效果。ListView的shrinkWrap设为true,让列表根据内容自动调整高度,而不是占据整个屏幕。这种设计让对话框大小适中,既不会太小显示不全,也不会太大浪费空间。

dart 复制代码
            children: [
              ListTile(
                title: const Text('无'),
                selected: _note.folderId == null,
                onTap: () {
                  setState(() {
                    _note = _note.copyWith(folderId: null);
                    _hasChanges = true;
                  });
                  Navigator.pop(context);
                },
              ),
              ...folders.map((f) => ListTile(
                title: Text(f.name),

children列表的第一项是"无"选项,表示不属于任何文件夹。selected属性根据_note.folderId是否为null判断是否选中,选中时会有高亮显示。点击时使用copyWith将folderId设为null,标记有修改,然后关闭对话框。使用展开运算符(...)将folders列表映射为ListTile列表并展开到children中,这是Dart中处理动态列表的常用技巧。每个文件夹创建一个ListTile,title显示文件夹名称。

dart 复制代码
                selected: _note.folderId == f.id,
                onTap: () {
                  setState(() {
                    _note = _note.copyWith(folderId: f.id);
                    _hasChanges = true;
                  });
                  Navigator.pop(context);
                },
              )),
            ],
          ),
        ),
      ),
    );
  }

selected属性判断当前文件夹是否被选中,通过比较笔记的folderId和文件夹的id。点击时更新笔记的folderId为选中文件夹的id,标记有修改,然后关闭对话框。这种列表选择的交互方式简单直观,用户可以快速找到目标文件夹。selected高亮让用户清楚知道当前笔记在哪个文件夹中,避免重复选择或选择错误。

分类选择器的实现

分类选择器与文件夹选择器类似,但显示分类的颜色标识。

dart 复制代码
  void _showCategorySelector() {
    final categories = _controller.categories;
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('选择分类'),
        content: SizedBox(
          width: double.maxFinite,
          child: ListView(
            shrinkWrap: true,

_showCategorySelector方法显示分类选择对话框,实现结构与文件夹选择器相同。从控制器获取所有分类列表,使用AlertDialog创建对话框,title显示"选择分类"。content同样使用SizedBox和ListView的组合,提供合适的显示区域。这种一致的交互模式降低了用户的学习成本,用户在使用文件夹选择器后可以无缝使用分类选择器。

dart 复制代码
            children: [
              ListTile(
                title: const Text('无分类'),
                selected: _note.categoryId == null,
                onTap: () {
                  setState(() {
                    _note = _note.copyWith(categoryId: null);
                    _hasChanges = true;
                  });
                  Navigator.pop(context);
                },
              ),
              ...categories.map((c) => ListTile(

第一个选项是"无分类",让用户可以清除笔记的分类设置。selected判断categoryId是否为null,点击时将categoryId设为null。后续使用展开运算符遍历所有分类,为每个分类创建ListTile。这种"无"选项的设计在很多选择器中都会用到,它给用户提供了"不选择"的权利,避免强制用户必须选择一个选项。

dart 复制代码
                leading: CircleAvatar(
                  backgroundColor: Color(int.parse(c.color.replaceFirst('#', '0xFF'))),
                  radius: 12,
                ),
                title: Text(c.name),
                selected: _note.categoryId == c.id,
                onTap: () {
                  setState(() {
                    _note = _note.copyWith(categoryId: c.id);
                    _hasChanges = true;
                  });
                  Navigator.pop(context);
                },
              )),

每个分类选项的leading显示一个CircleAvatar圆形头像组件,用作颜色指示器。backgroundColor通过解析分类的颜色字符串设置,将"#RRGGBB"格式转换为"0xFFRRGGBB"格式供Color构造函数使用。radius设为12创建一个小圆点。title显示分类名称,selected判断是否选中,点击时更新categoryId。这种带颜色标识的设计让分类选择器比文件夹选择器更加直观,用户可以通过颜色快速识别不同的分类,提升了视觉区分度和选择效率。

锁定功能的实现

锁定功能让用户可以为笔记设置密码保护。

dart 复制代码
  void _toggleLock() {
    if (_note.isLocked) {
      _showUnlockDialog();
    } else {
      _showSetPasswordDialog();
    }
  }

_toggleLock方法根据笔记的当前锁定状态决定显示哪个对话框。如果笔记已经锁定(isLocked为true),显示解锁对话框让用户输入密码解锁。如果笔记未锁定,显示设置密码对话框让用户设置密码并锁定笔记。这种双向切换的设计让同一个菜单选项可以处理锁定和解锁两种操作,简化了界面。

dart 复制代码
  void _showSetPasswordDialog() {
    final passwordController = TextEditingController();
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('设置密码'),
        content: TextField(
          controller: passwordController,
          obscureText: true,
          decoration: const InputDecoration(
            hintText: '输入密码',
            border: OutlineInputBorder(),
          ),
        ),

_showSetPasswordDialog方法显示设置密码对话框。创建一个TextEditingController管理密码输入,使用AlertDialog创建对话框,title显示"设置密码"。content是一个TextField,obscureText设为true将输入的字符显示为圆点,保护密码隐私。decoration使用OutlineInputBorder添加边框,让输入框更明显,hintText提示用户"输入密码"。这种密码输入框的设计是标准的安全实践,防止他人偷看密码。

dart 复制代码
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('取消'),
          ),
          ElevatedButton(
            onPressed: () {
              if (passwordController.text.isNotEmpty) {
                setState(() {
                  _note = _note.copyWith(
                    isLocked: true,
                    password: passwordController.text,
                  );
                  _hasChanges = true;
                });
                Navigator.pop(context);
                Get.snackbar('提示', '笔记已锁定', snackPosition: SnackPosition.BOTTOM);
              }
            },
            child: const Text('确定'),
          ),
        ],
      ),
    );
  }

对话框提供两个按钮:取消和确定。取消按钮直接关闭对话框,不做任何操作。确定按钮首先验证密码不为空,然后使用copyWith更新笔记对象,将isLocked设为true,password设为用户输入的密码,标记有修改。关闭对话框并显示"笔记已锁定"提示。这种简单的密码保护虽然不是最安全的加密方式(密码明文存储),但对于个人笔记应用的一般隐私保护需求已经足够,实现简单且用户体验好。

dart 复制代码
  void _showUnlockDialog() {
    final passwordController = TextEditingController();
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('输入密码'),
        content: TextField(
          controller: passwordController,
          obscureText: true,
          decoration: const InputDecoration(
            hintText: '输入密码解锁',
            border: OutlineInputBorder(),
          ),
        ),

_showUnlockDialog方法显示解锁对话框,结构与设置密码对话框类似。创建TextEditingController管理密码输入,title显示"输入密码",content是一个obscureText为true的TextField。hintText提示"输入密码解锁",让用户明确这是解锁操作。这种一致的对话框设计让用户容易理解,降低了认知负担。

dart 复制代码
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('取消'),
          ),
          ElevatedButton(
            onPressed: () {
              if (passwordController.text == _note.password) {
                setState(() {
                  _note = _note.copyWith(isLocked: false, password: null);
                  _hasChanges = true;
                });
                Navigator.pop(context);
                Get.snackbar('提示', '笔记已解锁', snackPosition: SnackPosition.BOTTOM);
              } else {
                Get.snackbar('错误', '密码错误', snackPosition: SnackPosition.BOTTOM);
              }
            },
            child: const Text('解锁'),
          ),
        ],
      ),
    );
  }

解锁对话框的确定按钮标签为"解锁",点击时验证输入的密码是否与笔记保存的密码匹配。如果匹配,使用copyWith将isLocked设为false,password设为null清除密码,标记有修改,关闭对话框并显示"笔记已解锁"提示。如果密码不匹配,显示"密码错误"提示但不关闭对话框,让用户可以重新输入。这种错误处理让用户有机会纠正输入错误,而不是一次失败就要重新打开对话框。整个锁定解锁功能为用户提供了基本的隐私保护,适合保护个人敏感笔记。

总结

笔记编辑器是应用的核心功能,它需要提供流畅的输入体验、丰富的编辑功能和可靠的保存机制。通过撤销重做、自动保存提示、标签颜色设置等功能的组合,我们实现了一个既强大又易用的编辑器系统。用户可以专注于写作,不用担心内容丢失或操作失误,整个编辑过程流畅自然。


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

相关推荐
时艰.2 小时前
JVM — Java 类加载机制
java·开发语言·jvm
小小码农Come on2 小时前
QT中窗口位置、相对位置、绝对位置
android·开发语言·qt
snow_star_dream2 小时前
(笔记)VSC python应用--函数补全注释添加
笔记·python
diediedei2 小时前
C++中的适配器模式变体
开发语言·c++·算法
songyuc2 小时前
【SAR】旋转框定义法学习笔记
笔记·学习
zilikew2 小时前
Flutter框架跨平台鸿蒙开发——小语种学习APP的开发流程
学习·flutter·华为·harmonyos·鸿蒙
郝学胜-神的一滴2 小时前
Python中的Mixin继承:灵活组合功能的强大模式
开发语言·python·程序人生
叫我:松哥2 小时前
基于python强化学习的自主迷宫求解,集成迷宫生成、智能体训练、模型评估等
开发语言·人工智能·python·机器学习·pygame
晚霞的不甘2 小时前
Flutter for OpenHarmony 创意实战:打造一款炫酷的“太空舱”倒计时应用
开发语言·前端·flutter·正则表达式·前端框架·postman