在这里插入图片描述

学习计划是时间管理的重要工具。通过制定每日计划,用户可以明确当天的学习任务,合理安排时间,提高学习效率。一个好的计划管理功能应该简单易用,让用户可以快速添加任务、标记完成状态、查看进度。在这篇文章中,我们将实现一个实用的每日计划功能。

功能需求分析

每日计划功能的核心是任务列表。用户可以看到当天的所有学习任务,每个任务包含标题、时间段和完成状态。用户可以通过勾选复选框来标记任务完成,已完成的任务会显示删除线。

任务列表应该支持添加新任务。点击页面底部的浮动按钮,弹出对话框让用户输入任务信息。任务信息包括标题和时间段,这些是必填项。

任务还应该支持编辑和删除。点击任务右侧的更多按钮,显示操作菜单,用户可以选择编辑或删除。编辑功能弹出对话框,预填充当前任务信息,用户修改后保存。

任务的排序也很重要。未完成的任务应该显示在前面,已完成的任务显示在后面。在同一状态内,按照时间顺序排列。这种排序方式让用户可以专注于未完成的任务。

页面结构设计

每日计划页面使用StatefulWidget实现,因为任务列表的状态会频繁变化。用户勾选复选框、添加任务、删除任务都会改变状态,需要重新渲染页面。

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';

class DailyPlanPage extends StatefulWidget {
  const DailyPlanPage({super.key});

  
  State<DailyPlanPage> createState() => _DailyPlanPageState();
}

class _DailyPlanPageState extends State<DailyPlanPage> {
  final List<Map<String, dynamic>> _plans = [
    {'title': '复习高数第三章', 'time': '09:00-10:30', 'completed': true},
    {'title': '背诵英语单词100个', 'time': '10:30-11:00', 'completed': true},
    {'title': '完成物理作业', 'time': '14:00-16:00', 'completed': false},
    {'title': '观看编程视频教程', 'time': '19:00-20:30', 'completed': false},
  ];

导入语句很简洁,只需要Flutter的基础包和ScreenUtil。这个页面不需要导航到其他页面,所以不需要GetX。

_plans列表存储所有的任务数据。这里使用Map来表示任务,包含title、time和completed三个字段。在实际应用中,应该定义一个Task类来表示任务,这样更规范,也有类型安全。

初始数据包含四个任务,其中两个已完成,两个未完成。这些数据用于演示,实际应用中应该从数据库加载。

页面布局实现

页面使用Scaffold提供标准布局,包含AppBar、Body和FloatingActionButton三个部分。

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('每日计划'),
      ),
      body: ListView.builder(
        padding: EdgeInsets.all(16.w),
        itemCount: _plans.length,
        itemBuilder: (context, index) {
          final plan = _plans[index];
          return Card(
            margin: EdgeInsets.only(bottom: 12.h),
            child: ListTile(
              leading: Checkbox(
                value: plan['completed'],
                onChanged: (value) {
                  setState(() {
                    _plans[index]['completed'] = value;
                  });
                },
              ),
              title: Text(
                plan['title'],
                style: TextStyle(
                  decoration: plan['completed'] ? TextDecoration.lineThrough : null,
                ),
              ),
              subtitle: Text(plan['time']),
              trailing: IconButton(
                icon: const Icon(Icons.more_vert),
                onPressed: () {},
              ),
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        child: const Icon(Icons.add),
      ),
    );
  }

AppBar只包含标题,没有其他按钮。标题使用const构造函数,这是一个性能优化的小技巧。

Body使用ListView.builder构建任务列表。builder方式只构建可见的列表项,对于长列表性能更好。虽然每日计划通常不会有很多任务,但使用builder是一个好习惯。

padding设置列表的内边距,让内容不会紧贴屏幕边缘。itemCount指定列表项的数量,itemBuilder是构建每个列表项的函数。

FloatingActionButton是Material Design中的标准组件,用于主要操作。这里用于添加新任务,图标是加号,含义很明确。

任务列表项的实现

每个任务使用Card包裹,Card提供了阴影和圆角,让列表项看起来像卡片。

return Card(
  margin: EdgeInsets.only(bottom: 12.h),
  child: ListTile(
    leading: Checkbox(
      value: plan['completed'],
      onChanged: (value) {
        setState(() {
          _plans[index]['completed'] = value;
        });
      },
    ),
    title: Text(
      plan['title'],
      style: TextStyle(
        decoration: plan['completed'] ? TextDecoration.lineThrough : null,
      ),
    ),
    subtitle: Text(plan['time']),
    trailing: IconButton(
      icon: const Icon(Icons.more_vert),
      onPressed: () {},
    ),
  ),
);

Card的margin只设置底部边距,让卡片之间有间隔。如果设置四周边距,左右会有多余的空白。

ListTile是Flutter提供的标准列表项组件,它自动处理了布局和间距。leading是左侧的组件,title是主标题,subtitle是副标题,trailing是右侧的组件。

Checkbox显示任务的完成状态。value参数绑定到任务的completed字段,onChanged回调在用户点击时触发。回调中使用setState更新状态,这会触发页面重建,复选框的状态就会更新。

标题文字根据完成状态显示不同的样式。如果任务已完成,添加删除线装饰。这种视觉反馈让用户清楚地看到哪些任务已完成。

副标题显示任务的时间段。时间信息帮助用户安排一天的学习节奏,避免任务冲突。

trailing是一个更多按钮,点击后可以显示编辑和删除选项。虽然目前点击没有实现功能,但预留了这个入口。

添加任务功能

点击浮动按钮应该弹出对话框,让用户输入新任务的信息。

void _showAddTaskDialog() {
  final titleController = TextEditingController();
  final timeController = TextEditingController();

  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('添加任务'),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          TextField(
            controller: titleController,
            decoration: const InputDecoration(
              labelText: '任务标题',
              hintText: '请输入任务标题',
            ),
            autofocus: true,
          ),
          SizedBox(height: 16.h),
          TextField(
            controller: timeController,
            decoration: const InputDecoration(
              labelText: '时间段',
              hintText: '例如:09:00-10:30',
            ),
          ),
        ],
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('取消'),
        ),
        TextButton(
          onPressed: () {
            if (titleController.text.isNotEmpty && timeController.text.isNotEmpty) {
              setState(() {
                _plans.add({
                  'title': titleController.text,
                  'time': timeController.text,
                  'completed': false,
                });
              });
              Navigator.pop(context);
            }
          },
          child: const Text('添加'),
        ),
      ],
    ),
  );
}

TextEditingController用于获取输入框的内容。每个输入框需要一个独立的controller。在对话框关闭后,这些controller会被自动释放,不需要手动dispose。

showDialog显示对话框。builder参数返回对话框的内容,这里使用AlertDialog,它是Material Design的标准对话框。

对话框的content使用Column垂直排列两个输入框。mainAxisSize设置为min让Column只占用需要的高度,而不是填满整个对话框。

第一个输入框设置autofocus为true,对话框打开时自动获得焦点,用户可以直接输入。这个小细节可以提升用户体验。

输入框使用decoration设置标签和提示文字。labelText是浮动标签,当输入框获得焦点时会浮动到上方。hintText是占位符,显示输入示例。

对话框底部有两个按钮:取消和添加。取消按钮直接关闭对话框,不做任何操作。添加按钮先验证输入是否为空,如果不为空,将新任务添加到列表,然后关闭对话框。

添加任务时使用setState,这会触发页面重建,新任务就会显示在列表中。新任务的completed字段默认为false,表示未完成。

完成状态切换

用户点击复选框可以切换任务的完成状态。这个功能已经在列表项中实现了。

Checkbox(
  value: plan['completed'],
  onChanged: (value) {
    setState(() {
      _plans[index]['completed'] = value;
    });
  },
),

onChanged回调接收一个布尔值参数,表示新的状态。我们直接将这个值赋给任务的completed字段。

使用setState包裹状态更新很重要。如果不使用setState,虽然数据会改变,但页面不会重建,用户看不到变化。

当任务状态改变时,标题的样式也会改变。已完成的任务显示删除线,未完成的任务正常显示。这是通过条件表达式实现的。

decoration: plan['completed'] ? TextDecoration.lineThrough : null,

这种即时的视觉反馈让用户清楚地知道操作成功了。相比于显示一个提示消息,这种方式更自然。

编辑任务功能

点击更多按钮应该显示操作菜单,包含编辑和删除选项。

void _showTaskMenu(int index) {
  showModalBottomSheet(
    context: context,
    builder: (context) => SafeArea(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          ListTile(
            leading: const Icon(Icons.edit),
            title: const Text('编辑'),
            onTap: () {
              Navigator.pop(context);
              _showEditTaskDialog(index);
            },
          ),
          ListTile(
            leading: const Icon(Icons.delete, color: Colors.red),
            title: const Text('删除', style: TextStyle(color: Colors.red)),
            onTap: () {
              Navigator.pop(context);
              _deleteTask(index);
            },
          ),
        ],
      ),
    ),
  );
}

showModalBottomSheet从底部弹出一个面板,这是移动应用中常见的交互模式。相比于对话框,底部面板更容易操作,特别是在大屏手机上。

SafeArea确保内容不会被系统UI遮挡,比如底部的手势条。这在全面屏手机上特别重要。

面板包含两个选项:编辑和删除。每个选项使用ListTile,包含图标和文字。删除选项使用红色,这是危险操作的标准颜色。

点击选项时,先关闭面板,然后执行相应的操作。如果不先关闭面板,会出现两个对话框叠加的情况。

编辑功能弹出对话框,预填充当前任务的信息。

void _showEditTaskDialog(int index) {
  final plan = _plans[index];
  final titleController = TextEditingController(text: plan['title']);
  final timeController = TextEditingController(text: plan['time']);

  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('编辑任务'),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          TextField(
            controller: titleController,
            decoration: const InputDecoration(labelText: '任务标题'),
          ),
          SizedBox(height: 16.h),
          TextField(
            controller: timeController,
            decoration: const InputDecoration(labelText: '时间段'),
          ),
        ],
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('取消'),
        ),
        TextButton(
          onPressed: () {
            if (titleController.text.isNotEmpty && timeController.text.isNotEmpty) {
              setState(() {
                _plans[index]['title'] = titleController.text;
                _plans[index]['time'] = timeController.text;
              });
              Navigator.pop(context);
            }
          },
          child: const Text('保存'),
        ),
      ],
    ),
  );
}

编辑对话框与添加对话框类似,主要区别是TextEditingController初始化时传入了当前的值。这样对话框打开时,输入框会显示当前的任务信息。

保存按钮点击时,更新对应索引的任务数据。使用setState触发页面重建,修改后的任务就会显示在列表中。

删除任务功能

删除任务需要用户确认,避免误操作。

void _deleteTask(int index) {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('确认删除'),
      content: Text('确定要删除任务"${_plans[index]['title']}"吗?'),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('取消'),
        ),
        TextButton(
          onPressed: () {
            setState(() {
              _plans.removeAt(index);
            });
            Navigator.pop(context);
          },
          style: TextButton.styleFrom(foregroundColor: Colors.red),
          child: const Text('删除'),
        ),
      ],
    ),
  );
}

确认对话框显示要删除的任务标题,让用户明确知道将要删除什么。这比简单地问"确定删除吗"更清楚。

删除按钮使用红色,强调这是危险操作。用户看到红色会更加谨慎。

确认后,使用removeAt方法从列表中删除指定索引的任务。同样需要使用setState触发页面重建。

任务排序优化

当前的任务列表按照添加顺序显示,但更好的方式是将未完成的任务显示在前面。

List<Map<String, dynamic>> get _sortedPlans {
  final plans = List<Map<String, dynamic>>.from(_plans);
  plans.sort((a, b) {
    if (a['completed'] == b['completed']) {
      return a['time'].compareTo(b['time']);
    }
    return a['completed'] ? 1 : -1;
  });
  return plans;
}

这个getter方法返回排序后的任务列表。首先复制原列表,避免修改原数据。然后使用sort方法排序。

排序规则是:如果两个任务的完成状态相同,按时间排序;否则,未完成的任务排在前面。

在ListView.builder中使用排序后的列表:

ListView.builder(
  itemCount: _sortedPlans.length,
  itemBuilder: (context, index) {
    final plan = _sortedPlans[index];
    // ...
  },
)

这样用户总是先看到未完成的任务,可以专注于当前需要做的事情。

数据持久化

当前的任务数据只存在于内存中,应用关闭后就会丢失。实际应用中应该将数据保存到本地数据库。

import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class TaskDatabase {
  static Database? _database;

  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDatabase();
    return _database!;
  }

  Future<Database> _initDatabase() async {
    final dbPath = await getDatabasesPath();
    final path = join(dbPath, 'tasks.db');

    return await openDatabase(
      path,
      version: 1,
      onCreate: (db, version) {
        return db.execute(
          'CREATE TABLE tasks(id INTEGER PRIMARY KEY, title TEXT, time TEXT, completed INTEGER)',
        );
      },
    );
  }

  Future<void> insertTask(Map<String, dynamic> task) async {
    final db = await database;
    await db.insert('tasks', task);
  }

  Future<List<Map<String, dynamic>>> getTasks() async {
    final db = await database;
    return await db.query('tasks');
  }

  Future<void> updateTask(int id, Map<String, dynamic> task) async {
    final db = await database;
    await db.update('tasks', task, where: 'id = ?', whereArgs: [id]);
  }

  Future<void> deleteTask(int id) async {
    final db = await database;
    await db.delete('tasks', where: 'id = ?', whereArgs: [id]);
  }
}

这个数据库类封装了所有的数据库操作。使用单例模式确保只有一个数据库实例。

在页面初始化时从数据库加载任务:


void initState() {
  super.initState();
  _loadTasks();
}

Future<void> _loadTasks() async {
  final db = TaskDatabase();
  final tasks = await db.getTasks();
  setState(() {
    _plans = tasks;
  });
}

添加、编辑、删除任务时,同时更新数据库:

void _addTask(String title, String time) async {
  final task = {'title': title, 'time': time, 'completed': 0};
  final db = TaskDatabase();
  final id = await db.insertTask(task);
  setState(() {
    _plans.add({...task, 'id': id});
  });
}

这样任务数据就会被持久化保存,应用重启后仍然可以看到之前的任务。

用户体验优化

添加一些细节可以提升用户体验。

当任务列表为空时,显示一个提示信息:

body: _plans.isEmpty
    ? Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.task_alt, size: 80.sp, color: Colors.grey[300]),
            SizedBox(height: 16.h),
            Text(
              '还没有任务',
              style: TextStyle(fontSize: 16.sp, color: Colors.grey),
            ),
            SizedBox(height: 8.h),
            Text(
              '点击右下角按钮添加任务',
              style: TextStyle(fontSize: 14.sp, color: Colors.grey[400]),
            ),
          ],
        ),
      )
    : ListView.builder(/* ... */),

空状态页面让用户知道如何开始使用功能,比空白页面更友好。

添加任务成功后,可以显示一个提示:

ScaffoldMessenger.of(context).showSnackBar(
  const SnackBar(content: Text('任务添加成功')),
);

SnackBar是Material Design的标准提示组件,从底部弹出,几秒后自动消失。

完成所有任务时,可以显示一个祝贺动画:

if (_plans.every((plan) => plan['completed'])) {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('🎉 太棒了!'),
      content: const Text('今天的所有任务都完成了!'),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('继续加油'),
        ),
      ],
    ),
  );
}

这种正向反馈可以激励用户持续使用应用。

总结

通过这篇文章,我们实现了每日计划功能。用户可以添加、编辑、删除任务,标记任务完成状态。任务列表会自动排序,将未完成的任务显示在前面。

我们使用了StatefulWidget管理页面状态,使用ListView.builder构建任务列表,使用对话框和底部面板实现交互。我们还讨论了数据持久化和用户体验优化。

在下一篇文章中,我们将实现学习计时器功能。计时器可以记录学习时长,帮助用户量化学习投入。这个功能涉及到Timer的使用、时间格式化、数据统计等知识点。


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

Logo

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

更多推荐