site infoHacknerd | Tech Blog
blog cover

📮 [Flutter] 从设计到构建到发布:APP应用开发经验总结

FlutterAndroidIOS

前言

对《Flutter实战·第二版》经过一周左右的学习,决定入手开发小型项目,以对书中的知识进行巩固。刚好女朋友想要一个属于我们的记录代办、家务、菜谱的应用,于是就有了这个项目。

模型定义

应用模块分为四块(代办,家务,菜谱,收入)。考虑的研发成本,目前仅采用客户端进行数据的存储和获取,采用安卓轻量级存储类 SharedPreferences进行数据存储。

  • 1.SharedPreferences:存储 JOSN 字符串,应用 初始化 前加载存储数据。当GlobalData更新时,更新存储数据。
  • 2.GlobalData:消费订阅模式,当数据更新便通知对应消费者,更新视图。
  • 3.todos: 数据模型todos。封装了CRUD、finish等接口,储存已完成未完成的todo 。储存开始结束时间、完成时间、对应的家务或自定代办。在新增时可选houswork 作为代办任务。当完成的todo为houswork时,更新 income。
  • 4.houseworks: 家务模型,封装CRUD。储存对应家务和价格。
  • 5.recipes:菜谱,储存菜谱做法。
  • 6.incomes: 收入每当完成一个家务,收入会对应增长。
  • image

    代码实现

    模型

    在 root_dir\jsons 新建储存数据。
    image

    模型定义

    todo.json

    jsonCopy
    {
      "id": "id",
      "name": "测试",
      "description?": "描述",
      "taskId?": "家务id", 
      "task?": "$houseWork",
      "startTime?": "2011-01-26T19:06:43Z",
      "endTime?": "2011-01-26T19:06:43Z",
      "finishTime?": "2011-01-26T19:06:43Z",
      "finished": false,
      "deleted": false
    }

    ecipe.json

    jsonCopy
    {
      "id": "id",
      "name": "name",
      "description?": "描述",
      "banner?": "banner",
      "steps": "做法",
      "deleted": false
    }

    income.json

    jsonCopy
    {
      "total": 0
    }

    houseWork.json

    jsonCopy
    {
      "id": "id",
      "name": "name",
      "banner?": "封面",
      "description?": "描述",
      "worth": 0,
      "deleted": false
    }

    生成模型

    在 pubspec.yaml 中添加json_serializable: ^1.0.0 json_model: ^6.7.1 依赖。

    执行 flutter packages pub run json_model 将jsons 目录下的json 转化为 JSON Model 输出在 lib/model 目录。

    image

    用于Model 和 JSON之间相互转换。

    全局变量

    用于数据存储和初始化 。lib/Global/global_data.dart 。
    dartCopy
    class GlobalData {
      static UserData userData = UserData()
        ..houseWorks = []
        ..incomes = Income()
        ..todos = []
        ..recipes = [];
    
      static late SharedPreferences sharedPreferences;
    
      // 初始化
      static Future init() async {
        WidgetsFlutterBinding.ensureInitialized();
        sharedPreferences = await SharedPreferences.getInstance();
    
        var houseWorksData = sharedPreferences.getString("houseWorks");
        var todoData = sharedPreferences.getString("todos");
        var recipeData = sharedPreferences.getString("recipes");
        var incomeData = sharedPreferences.getString("incomes");
    
        try {
          if (houseWorksData != null) {
            userData.houseWorks = (jsonDecode(houseWorksData) as List<dynamic>)
                .map((element) => HouseWork.fromJson(element))
                .toList();
          }
          if (todoData != null) {
            userData.todos = (jsonDecode(todoData) as List<dynamic>)
                .map((element) => Todo.fromJson(element))
                .toList();
          }
          if (incomeData != null) {
            userData.incomes = Income.fromJson(jsonDecode(incomeData));
          }
          if (recipeData != null) {
            userData.recipes = (jsonDecode(recipeData) as List<dynamic>)
                .map((element) => Recipe.fromJson(element))
                .toList();
          }
        } catch (e) {
          print(e);
        }
      }
    
      // 保存数据
      static saveUserData() {
        sharedPreferences.setString("houseWorks", jsonEncode(userData.houseWorks));
        sharedPreferences.setString("todos", jsonEncode(userData.todos));
        sharedPreferences.setString("recipes", jsonEncode(userData.recipes));
        sharedPreferences.setString(
            "incomes", jsonEncode(userData.incomes.toJson()));
      }
    }

    共享状态

    pubspec.yml 添加 provider: ^6.1.2 依赖(用于共享状态监听,当模块对应数据变更时,通知UI进行更新)。

    global_data_notifier.dart

    dartCopy
    
    class GloabalDataNotifier extends ChangeNotifier {
      UserData get userData {
        return GlobalData.userData;
      }
    
      @override
      void notifyListeners() {
        GlobalData.saveUserData();
        super.notifyListeners();
      }
    }

    todo_data.dart

    dartCopy
    class TodoActions {
      static String add = 'add';
      static String delete = 'delete';
      static String update = 'update';
      static String finish = 'finish';
    }
    
    class TodoData extends GloabalDataNotifier {
      late int length;
      late int finishedLength;
      late int total;
      TodoData() {
        length = todos.length ?? 0;
        finishedLength = finishedList.length ?? 0;
        total = length + finishedLength;
      }
    
      static Todo generateData(Todo? houseWork) {
        if (houseWork != null) {
          return houseWork;
        } else {
          return Todo()
            ..id = const Uuid().v8()
            ..finished = false
            ..deleted = false;
        }
      }
    
      List<Todo> get todos {
        return userData.todos.where((todo) {
          return !todo.deleted && !todo.finished;
        }).toList();
      }
    
      List<Todo> get finishedList {
        return userData.todos.where((todo) {
          return !todo.deleted && todo.finished;
        }).toList();
      }
    
      double get progress {
        return total == 0 ? .0 : finishedLength / total;
      }
    
      void updateLength(String action, [bool finished = false]) {
        if (action == TodoActions.add) {
          length++;
          total++;
        } else if (action == TodoActions.finish) {
          length--;
          finishedLength++;
        } else if (action == TodoActions.delete) {
          finished ? finishedLength-- : length--;
          total--;
        }
      }
    
      void add(Todo todo) {
        updateLength(TodoActions.add);
        userData.todos.add(todo);
        notifyListeners();
      }
    
      void delete(Todo todo) {
        final index = findIndex(todo);
        if (index != -1) {
          updateLength(TodoActions.delete, userData.todos[index].finished);
          userData.todos[index].deleted = true;
          notifyListeners();
        }
      }
    
      void update(Todo todo) {
        final index = findIndex(todo);
        if (index != -1) {
          userData.todos[index] = todo;
          notifyListeners();
        }
      }
    
      void finish(Todo todo) {
        final index = findIndex(todo);
        if (index != -1) {
          updateLength(TodoActions.finish);
          userData.todos[index]
            ..finished = true
            ..finishTime = DateFormat("yyyy-MM-dd HH:mm").format(DateTime.now());
          notifyListeners();
        }
      }
    
      int findIndex(Todo todo) {
        return userData.todos.indexWhere((element) {
          return element.id == todo.id;
        });
      }
    }

    house_work.dart

    dartCopy
    class HouseWorkData extends GloabalDataNotifier {
      List<HouseWork> get houseWorks {
        return userData.houseWorks
            .where((houseWork) => houseWork.deleted == false)
            .toList();
      }
    
      get length {
        return userData.houseWorks.length ?? 0;
      }
    
      static String getPrice(HouseWork houseWork) {
        return "¥${houseWork.worth}";
      }
    
      static HouseWork generateData(HouseWork? houseWork) {
        if (houseWork != null) {
          return houseWork;
        } else {
          return HouseWork()
            ..id = const Uuid().v8()
            ..deleted = false;
        }
      }
    
      add(HouseWork houseWork) {
        userData.houseWorks.add(houseWork);
        notifyListeners();
      }
    
      update(HouseWork houseWork) {
        final index = findIndex(houseWork);
        if (index != -1) {
          userData.houseWorks[index] = houseWork;
          notifyListeners();
        }
      }
    
      delete(HouseWork houseWork) {
        final index = findIndex(houseWork);
        if (index != -1) {
          userData.houseWorks[index].deleted = true;
          notifyListeners();
        }
      }
    
      int findIndex(HouseWork houseWork) {
        return userData.houseWorks.indexWhere((element) {
          return element.id == houseWork.id;
        });
      }
    
      HouseWork find(String id) {
        return userData.houseWorks.firstWhere((element) {
          return element.id == id;
        });
      }
    }

    recipe_data.dart

    dartCopy
    
    class RecipeData extends GloabalDataNotifier {
      static Recipe generateData(Recipe? houseWork) {
        if (houseWork != null) {
          return houseWork;
        } else {
          return Recipe()
            ..id = const Uuid().v8()
            ..deleted = false;
        }
      }
    
      List<Recipe> get recipes {
        return userData.recipes.where((recipe) => recipe.deleted == false).toList();
      }
    
      set recipes(List<Recipe> recipe) {
        userData.recipes = recipe;
        notifyListeners();
      }
    
      get length {
        return recipes.length ?? 0;
      }
    
      void add(Recipe recipe) {
        userData.recipes.add(recipe);
        notifyListeners();
      }
    
      void delete(Recipe recipe) {
        final index = findIndex(recipe);
        if (index != -1) {
          userData.recipes[index].deleted = true;
        }
        notifyListeners();
      }
    
      void update(Recipe recipe) {
        final index = findIndex(recipe);
        if (index != -1) {
          userData.recipes[index] = recipe;
          notifyListeners();
        }
      }
    
      int findIndex(Recipe recipe) {
        return userData.recipes.indexWhere((element) {
          return element.id == recipe.id;
        });
      }
    }

    income.dart

    dartCopy
    class IncomeData extends GloabalDataNotifier {
      Income get incomeData {
        return userData.incomes;
      }
    
      set incomeData(Income income) {
        userData.incomes = income;
        notifyListeners();
      }
    
      get total {
        return userData.incomes.total ?? 0;
      }
    
      void updateIncome(Todo todo) {
        if (todo.task != null) {
          incomeData.total += todo.task!.worth;
        }
        notifyListeners();
      }
    }

    根节点

    main.dart

    dartCopy
    void main() {
    	// 等待SharedPreferencesc初始化
      GlobalData.init().then((e) => runApp(const MyApp()));
    }
    
    class MyApp extends StatefulWidget {
      const MyApp({super.key});
    
      @override
      State<MyApp> createState() => _MyAppState();
    }
    
    class _MyAppState extends State<MyApp> {
      @override
      Widget build(BuildContext context) {
        return MultiProvider( // 创建provider
            providers: [
              ChangeNotifierProvider(
                create: (context) => HouseWorkData(),
              ),
              ChangeNotifierProvider(
                create: (context) => IncomeData(),
              ),
              ChangeNotifierProvider(
                create: (context) => RecipeData(),
              ),
              ChangeNotifierProvider(
                create: (context) => TodoData(),
              ),
            ],
            child: MaterialApp(
              theme: ThemeData(
                useMaterial3: true,
              ),
              locale: const Locale('zh', 'CN'),
              home: const HomePage(),
              localizationsDelegates: const [
                GlobalMaterialLocalizations.delegate,
                GlobalWidgetsLocalizations.delegate,
                GlobalCupertinoLocalizations.delegate
              ],
              routes: { // 创建provider创建路由
                MenuConfig.houseWork.path: (context) => const HouseWorkPage(),
                MenuConfig.recipe.path: (context) => const RecipePage(),
                MenuConfig.income.path: (context) => const IncomePage()
              },
            ));
      }
    }

    维护路由

    menu_config.dart

    dartCopy
    
    class MenuItem {
      MenuItem(
          {required this.label,
          this.icon,
          required this.path,
          this.build,
          required this.name});
    
      String label;
      String path;
      String name;
      IconData? icon;
      Widget Function()? build;
    }
    
    class MenuConfig {
      static MenuItem todo = MenuItem(
          name: 'todo',
          label: '代办',
          path: "/",
          icon: IconFont.icon_1_todo,
          build: () => const TodoPage());
      static MenuItem recipe = MenuItem(
          name: 'recipe',
          label: '菜谱',
          path: "recipe",
          icon: IconFont.icon_repast_recipe,
          build: () => const RecipePage());
      static MenuItem houseWork = MenuItem(
          name: 'housework',
          label: '家务',
          path: "housework",
          icon: Icons.work_outline,
          build: () => const HouseWorkPage());
      static MenuItem income = MenuItem(
          name: "income",
          label: '收入',
          path: "income",
          icon: IconFont.icon_1_income_2,
          build: () => const IncomePage());
    
      static List<MenuItem> menus = [
        MenuConfig.todo,
        MenuConfig.recipe,
        MenuConfig.houseWork,
        MenuConfig.income
      ];
    
      static renderMenu() {
        return menus.map((menu) => menu.build!()).toList();
      }
    }

    引入Iconfont

    下载iconfont.ttf
    image

    维护 assets/fonts/iconfont.ttf

    image

    pubspec.yaml 引入iconfont资源

    yamlCopy
    flutter:
      # The following line ensures that the Material Icons font is
      # included with your application, so that you can use the icons in
      # the material Icons class.
      uses-material-design: true
    
      fonts:
        - family: IconFont
          fonts:
            - asset: assets/fonts/iconfont.ttf

    维护Iconfont 类,icon_font.dart 。IconData中的 iconCode 可以在 demo_index.html 中查看

    image
    javascriptCopy
    class IconFont {
      static const String _family = 'IconFont';
    	
      static const IconData icon_kongshuju = IconData(0xe63a, fontFamily: _family);
    
      static const IconData icon_income_one = IconData(0xe6b0, fontFamily: _family);
    
      static const IconData icon_1_todo = IconData(0xe600, fontFamily: _family);
    
      static const IconData icon_1_income_2 = IconData(0xe601, fontFamily: _family);
      
      static const IconData icon_income = IconData(0xe906, fontFamily: _family);
    
      static const IconData icon_repast_recipe =
          IconData(0xe649, fontFamily: _family);
    }

    之后通过IconFont.${icon} 调用Iconfont图标。

    通用组件

    empty.dart 。空数据展示。

    image
    dartCopy
    class EmptyWiget extends StatelessWidget {
      const EmptyWiget({super.key});
    
      @override
      Widget build(BuildContext context) {
        return const Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(
                IconFont.icon_kongshuju,
                size: 60.0,
              ),
              SizedBox(
                height: 20,
              ),
              Text("暂无数据")
            ],
          ),
        );
      }
    }

    hw_card.dart

    dartCopy
    class HwCardWiget extends StatefulWidget {
      const HwCardWiget(
          {super.key,
          required this.title,
          this.description = "",
          this.detail = "",
          this.disable = false,
          this.actions,
          this.onDelete,
          this.onEdit,
          this.onFinished,
          this.finished = false});
    
      final String title;
    
      final String description;
    
      final String detail;
    
    	// 下拉按钮选项 支持删除 编辑 完成
      final List<HwCardActionItem>? actions;
    
    	// 删除回调
      final Function? onDelete;
    
    	// 编辑回调
      final Function? onEdit;
    
    	// 完成回调
      final Function? onFinished;
    
      final bool disable;
    
      final bool finished;
    
      @override
      State<HwCardWiget> createState() => _HwCardWigetState();
    }
    
    class _HwCardWigetState extends State<HwCardWiget> {
      // controler
      ExpansionTileController expansionTileController = ExpansionTileController();
    
      late TextStyle textStyle;
    
      @override
      void initState() {
        super.initState();
        textStyle = TextStyle(
            decoration:
                widget.finished ? TextDecoration.lineThrough : TextDecoration.none // 如果是完成状态 则生成删除线
        );
      }
    
      // 控制展开收起
      void toggleExpand() {
        if (widget.disable) return;
        if (expansionTileController.isExpanded) {
          expansionTileController.collapse();
        } else {
          expansionTileController.expand();
        }
      }
    
      @override
      Widget build(BuildContext context) {
        return Card(
            margin: const EdgeInsets.only(bottom: 12),
            color: const Color.fromRGBO(254, 247, 255, 1),
            child: InkWell( // 点击产生水波纹效果
                onTap: toggleExpand,
                child: Theme(
                    data: ThemeData().copyWith(dividerColor: Colors.transparent),
                    child: ExpansionTile(
                      controller: expansionTileController,
                      enabled: false,
                      expandedAlignment: Alignment.centerLeft,
                      childrenPadding:
                          const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 16.0),
                      leading: ClipOval(
                          child: Container(
                        width: 30,
                        height: 30,
                        alignment: Alignment.center,
                        color: const Color.fromRGBO(103, 80, 164, 1),
                        child: Text(
                          widget.title[0],
                          style: const TextStyle(color: Colors.white),
                        ),
                      )),
                      trailing: PopupMenuButton(
    	                  // 下拉按钮
                        enabled: widget.actions?.isNotEmpty ?? false,
                        itemBuilder: (context) => widget.actions != null
                            ? widget.actions!
                                .map(
                                  (action) => PopupMenuItem(
                                    value: action.key,
                                    child: Text(action.label),
                                  ),
                                )
                                .toList()
                            : [],
                        onSelected: (value) {
                          if (value == HwCardActions.edit.key) {
                            if (widget.onEdit != null) {
                              widget.onEdit!();
                            }
                          } else if (value == HwCardActions.delete.key) {
                            if (widget.onDelete != null) {
                              widget.onDelete!();
                            }
                          } else if (value == HwCardActions.compelete.key) {
                            if (widget.onFinished != null) {
                              widget.onFinished!();
                            }
                          }
                        },
                      ),
                      title: Text(widget.title,
                          style: textStyle.copyWith(
                              color: const Color.fromARGB(255, 1, 1, 1),
                              fontSize: 16)),
                      subtitle: widget.description.isNotEmpty
                          ? Text(
                              widget.description,
                              style: textStyle.copyWith(
                                  color: const Color.fromARGB(200, 73, 69, 79)),
                            )
                          : null,
                      children: [Text(widget.detail, style: textStyle)],
                    ))));
      }
    }
    

    message.dart 全局生成SnackBar 进行消息提示。

    dartCopy
    import 'package:flutter/material.dart';
    
    SnackBar createMessage(String content) {
      return SnackBar(content: Text(content));
    }

    界面

    home_page.dart 首页,配置侧边栏导航,渲染界面。

    dartCopy
    class HomePage extends StatefulWidget {
      const HomePage({super.key});
    
      @override
      State<HomePage> createState() => _HomePageState();
    }
    
    class _HomePageState extends State<HomePage> {
      final List<Widget> _menus = MenuConfig.renderMenu();
      int _selectedIndex = 0;
      @override
      Widget build(BuildContext context) {
        final HouseWorkData houseWorkData = Provider.of<HouseWorkData>(context);
    
        final navigationData = {
          MenuConfig.todo.name: Provider.of<TodoData>(context).length,
          MenuConfig.recipe.name: Provider.of<RecipeData>(context).length,
          MenuConfig.houseWork.name: Provider.of<HouseWorkData>(context).length,
          MenuConfig.income.name: Provider.of<IncomeData>(context).total,
        };
    
        String renderCount(MenuItem item) {
          return "${item.name == MenuConfig.income.name ? "¥" : " "}${navigationData[item.name].toString()}";
        }
    
        return Scaffold(
          appBar: AppBar(
            title: Text(MenuConfig.menus[_selectedIndex].label),
          ),
          drawer: NavigationDrawer(
            selectedIndex: _selectedIndex,
            children: [
              Container(
                padding: const EdgeInsets.fromLTRB(30, 40, 30, 40),
                child: const Text("Menu"),
              ),
              ...MenuConfig.menus.map((menu) => NavigationDrawerDestination(
                  icon: Icon(menu.icon),
                  label: Expanded(
                    child: Container(
                      padding: const EdgeInsets.only(right: 30),
                      child: Row(
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,
                        crossAxisAlignment: CrossAxisAlignment.center,
                        children: [Text(menu.label), Text(renderCount(menu))],
                      ),
                    ),
                  )))
            ],
            onDestinationSelected: (index) {
              Navigator.of(context).pop();
              setState(() {
                _selectedIndex = index;
              });
            },
          ),
          body: Container(
            padding: EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 16.0),
            child: _menus[_selectedIndex],
          ),
        );
      }
    } 

    todo_page.dart

    dartCopy
    class TodoPage extends StatefulWidget {
      const TodoPage({super.key});
    
      @override
      State<TodoPage> createState() => _TodoPageState();
    }
    
    class _TodoPageState extends State<TodoPage>
        with SingleTickerProviderStateMixin {
      late AnimationController _animationController;
    
      late double progress = .0;
    
      @override
      void initState() {
        _animationController = AnimationController(
          vsync: this, //注意State类需要混入SingleTickerProviderStateMixin(提供动画帧计时/触发器)
          duration: Duration(microseconds: 1000),
        );
        _animationController.addListener(() => setState(() => {}));
        super.initState();
      }
    
      @override
      void dispose() {
        super.dispose();
        _animationController.dispose();
      }
    
      void startAnimate(TodoData todo) {
    	  // build时判断是否需要更新进度条
        if (progress != todo.progress) {
          progress = todo.progress;
          _animationController.animateTo(todo.progress);
        }
      }
    
      @override
      Widget build(BuildContext context) {
        TodoData todoModel = Provider.of<TodoData>(context);
        startAnimate(todoModel);
        return Scaffold(
          body: todoModel.todos.isNotEmpty || todoModel.finishedList.isNotEmpty
              ? ListView(children: [
                  Container(
                    padding: EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 20.0),
                    child: LinearProgressIndicator(
                      value: _animationController.value,
                      borderRadius: BorderRadius.all(Radius.circular(100.0)),
                    ),
                  ),
                  ...renderTodos(),
                  Container(
                      padding: const EdgeInsets.symmetric(vertical: 10.0),
                      child: const Divider(height: 1.0)),
                  ...renderFnished()
                ])
              : const EmptyWiget(),
          floatingActionButton: FloatingActionButton(
            onPressed: () {
              openTodoFromDialog(context);
            },
            child: const Icon(
              Icons.add,
            ),
          ),
        );
      }
    
      renderTodos() {
        TodoData todoModel = Provider.of<TodoData>(context);
        IncomeData incomeData = Provider.of<IncomeData>(context);
        return todoModel.todos.map((todo) => HwCardWiget(
              title: todo.name,
              description: renderRemainDate(todo.endTime),
              detail: renderDetail(todo),
              actions: [
                HwCardActions.compelete,
                HwCardActions.edit,
                HwCardActions.delete,
              ],
              onDelete: () {
                todoModel.delete(todo);
              },
              onEdit: () {
                openTodoFromDialog(context, todo, true);
              },
              onFinished: () {
                todoModel.finish(todo);
                incomeData.updateIncome(todo);
              },
            ));
      }
    
      String renderRemainDate(String? endTime) {
        if (endTime == null) return "";
        var end = DateTime.parse(endTime);
        var now = DateTime.now();
        var remain = end.difference(now);
        var remainDay = remain.inDays.abs();
        var remainHours = remain.inHours.abs() % 24;
    
        return "${remain.isNegative ? "超时" : "剩余"} ${remainDay == 0 ? "" : "${remainDay}天 "}${remainDay == 0 && remainHours == 0 ? "" : "${remainHours}分钟 "}${remain.inMinutes % 60} 分钟";
      }
    
      String renderTime(String? startTime, String? endTime) {
        return "$startTime - $endTime";
      }
    
      String renderDetail(Todo todo) {
        var result = "";
        if (todo.description != null && todo.description != "") {
          result += "描述:\n${todo.description}\n";
        }
        if (todo.task != null) {
          result += "家务:\n${todo.task!.name}\n价值:\n¥${todo.task!.worth}\n";
        }
        result += "时间:\n${renderTime(todo.startTime, todo.endTime)}\n";
        return result;
      }
    
      renderFnished() {
        TodoData todoModel = Provider.of<TodoData>(context);
        return todoModel.finishedList.map((todo) => HwCardWiget(
            title: todo.name,
            description: "完成时间: ${todo.finishTime}",
            detail: renderDetail(todo),
            finished: true));
      }
    }

    recipe_page.dart

    dartCopy
    class RecipePage extends StatelessWidget {
      const RecipePage({super.key});
    
      @override
      Widget build(BuildContext context) {
        final houseModel = Provider.of<RecipeData>(context);
        return Scaffold(
          body: houseModel.recipes.isEmpty
              ? const EmptyWiget()
              : ListView(
                  children: houseModel.recipes
                      .map((recipe) => HwCardWiget(
                          title: recipe.name,
                          detail: "做法:\n${recipe.steps}",
                          actions: [
                            HwCardActions.edit,
                            HwCardActions.delete,
                          ],
                          onDelete: () {
                            Provider.of<RecipeData>(context, listen: false)
                                .delete(recipe);
                          },
                          onEdit: () {
                            openRecipeFromDialog(context, recipe, true);
                          }))
                      .toList(),
                ),
          floatingActionButton: FloatingActionButton(
            onPressed: () {
              openRecipeFromDialog(context);
            },
            child: const Icon(
              Icons.add,
            ),
          ),
        );
      }
    }
    

    house_work.dart

    dartCopy
    class HouseWorkPage extends StatelessWidget {
      const HouseWorkPage({super.key});
    
      @override
      Widget build(BuildContext context) {
    	  // 订阅 HouseWorkData HouseWorkData 数据变更 重新build Widget
        final houseModel = Provider.of<HouseWorkData>(context);
        return Scaffold(
          body: houseModel.houseWorks.isEmpty
              ? EmptyWiget()
              : ListView(
                  children: houseModel.houseWorks
                      .map((houseWork) => HwCardWiget(
                          title: houseWork.name,
                          detail: "价值 ${HouseWorkData.getPrice(houseWork)}",
                          actions: [
                            HwCardActions.edit,
                            HwCardActions.delete,
                          ],
                          onDelete: () {
                            Provider.of<HouseWorkData>(context, listen: false)
                                .delete(houseWork);
                          },
                          onEdit: () {
                            openHouseWorkFromDialog(context, houseWork, true);
                          }))
                      .toList(),
                ),
          floatingActionButton: FloatingActionButton(
            onPressed: () {
    	        // 打开新增弹窗
              openHouseWorkFromDialog(context);
            },
            child: const Icon(
              Icons.add,
            ),
          ),
        );
      }
    }

    income_page.dart

    dartCopy
    class IncomePage extends StatelessWidget {
      const IncomePage({super.key});
    
      @override
      Widget build(BuildContext context) {
        final incomeData = Provider.of<IncomeData>(context).incomeData;
        return Scaffold(
          body: Column(
            children: [
              CalendarDatePicker(
                initialDate: DateTime.now(),
                firstDate: DateTime.now(),
                lastDate: DateTime.now(),
                onDateChanged: (value) {},
              ),
              SizedBox(height: 20),
              Icon(
                IconFont.icon_income_one,
                size: 40.0,
              ),
              SizedBox(height: 10),
              Text(
                "总收入 ¥${incomeData.total}",
              )
            ],
          ),
        );
      }
    }

    表单

    todo_form.dart

    dartCopy
    class TodoForm extends StatefulWidget {
      const TodoForm({super.key, this.isEdit = false, this.data});
    
      final bool isEdit;
    
      final Todo? data;
    
      @override
      State<TodoForm> createState() => _TodoFormState();
    }
    
    class _TodoFormState extends State<TodoForm> {
      /* ---- 表单 ---- */
      // 代办名
      final TextEditingController _nameInputControl = TextEditingController();
      // 任务
      final TextEditingController _taskInputControl = TextEditingController();
      // 开始时间
      final TextEditingController _startTimeControl = TextEditingController();
      // 开始时间
      final TextEditingController _endTimeControl = TextEditingController();
      // 开始时间
      final TextEditingController _descriptionControl = TextEditingController();
    
      final GlobalKey _formKey = GlobalKey<FormState>();
    
      @override
      void initState() {
        super.initState();
        if (widget.isEdit && widget.data != null) {
          _nameInputControl.value = TextEditingValue(text: widget.data!.name);
          _taskInputControl.value =
              TextEditingValue(text: widget.data?.taskId ?? '');
          _startTimeControl.value =
              TextEditingValue(text: widget.data?.startTime ?? '');
          _endTimeControl.value =
              TextEditingValue(text: widget.data?.endTime ?? '');
          _descriptionControl.value =
              TextEditingValue(text: widget.data?.description ?? '');
        }
      }
    
      @override
      Widget build(BuildContext context) {
        final List<HouseWork> houseWorks =
            Provider.of<HouseWorkData>(context).houseWorks;
    
        return Container(
          padding: const EdgeInsets.all(8.0),
          child: ListView(
            children: [
              Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
                IconButton(onPressed: _closeDialog, icon: const Icon(Icons.close)),
                Text(widget.isEdit ? "编辑代办" : "新增代办"),
                TextButton(onPressed: _validateHandler, child: const Text("保存"))
              ]),
              Container(
                padding: const EdgeInsets.all(8.0),
                child: Form(
                    key: _formKey,
                    autovalidateMode: AutovalidateMode.onUserInteraction,
                    child: Column(
                      children: [
                        TextFormField(
                          controller: _nameInputControl,
                          decoration: const InputDecoration(
                              label: Text("名称"),
                              hintText: "请输入名称",
                              border: OutlineInputBorder(),
                              helperText: "(必填) 任务名称"),
                          validator: (value) =>
                              validatorUtilnotEmpty(value, "请输入名称"),
                        ),
                        const SizedBox(
                          height: 20.0,
                        ),
                        SelectFormField(
                            type: SelectFormFieldType.dropdown,
                            controller: _taskInputControl,
                            textInputAction: TextInputAction.none,
                            enabled: houseWorks.isEmpty != true,
                            decoration: InputDecoration(
                                label: const Text("家务"),
                                border: const OutlineInputBorder(),
                                helperText:
                                    houseWorks.isEmpty ? "请先添加家务" : "(可选) 家务"),
                            items: houseWorks
                                .map<Map<String, dynamic>>((houseWork) => ({
                                      'label': houseWork.name,
                                      'value': houseWork.id
                                    }))
                                .toList()),
                        const SizedBox(
                          height: 20.0,
                        ),
                        TextFormField(
                          controller: _descriptionControl,
                          maxLines: 6,
                          decoration: const InputDecoration(
                              label: Text("描述"),
                              hintText: "请输入描述",
                              border: OutlineInputBorder(),
                              helperText: "(可选) 任务描述"),
                        ),
                        const SizedBox(
                          height: 20.0,
                        ),
                        TextFormField(
                          controller: _startTimeControl,
                          onTap: () {
                            _showTimePicker(context);
                          },
                          readOnly: true,
                          textInputAction: TextInputAction.none,
                          decoration: const InputDecoration(
                              label: Text("开始时间"),
                              hintText: "请选择开始时间",
                              border: OutlineInputBorder(),
                              helperText: "(必填) 开始时间"),
                          validator: (value) =>
                              validatorUtilnotEmpty(value, "请选择开始时间"),
                        ),
                        const SizedBox(
                          height: 20.0,
                        ),
                        TextFormField(
                          readOnly: true,
                          // readOnly: true,
                          controller: _endTimeControl,
                          textInputAction: TextInputAction.none,
                          enabled: _startTimeControl.text.isEmpty != true,
                          onTap: () {
                            _showTimePicker(context, true);
                          },
                          decoration: const InputDecoration(
                              label: Text("结束时间"),
                              hintText: "请选择结束时间",
                              border: OutlineInputBorder(),
                              helperText: "(必填) 结束时间"),
                          validator: (value) =>
                              validatorUtilnotEmpty(value, "请选择结束时间"),
                        ),
                      ],
                    )),
              ),
            ],
          ),
        );
      }
    
      // 关闭弹窗
      _closeDialog() {
        Navigator.of(context).pop();
      }
    
      _validateHandler() {
        if ((_formKey.currentState as FormState).validate()) {
          Todo todo = TodoData.generateData(widget.data)
            ..name = _nameInputControl.text
            ..startTime = _startTimeControl.text
            ..taskId = _taskInputControl.text
            ..endTime = _endTimeControl.text
            ..description = _descriptionControl.text;
    
          if (todo.taskId != null && todo.taskId != "") {
            todo.task = Provider.of<HouseWorkData>(context, listen: false)
                .find(todo.taskId!);
          }
          if (widget.isEdit) {
            Provider.of<TodoData>(context, listen: false).update(todo);
          } else {
            Provider.of<TodoData>(context, listen: false).add(todo);
          }
          ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
              content: Text("保存成功"), duration: Duration(milliseconds: 300)));
          _closeDialog();
        }
      }
    
      // 选择时间选项
      _showTimePicker(BuildContext context, [bool isEnd = false]) async {
        final firstDate = isEnd
            ? DateTime.parse(_startTimeControl.text)
            : DateTime(DateTime.now().year - 10);
        final lastDate = isEnd
            ? DateTime(firstDate.year + 10)
            : DateTime(DateTime.now().year + 10);
        // 打开日期选择弹窗
        final date = await showDatePicker(
            context: context, firstDate: firstDate, lastDate: lastDate);
        if (date == null) return;
        // 打开时间选择弹窗
        final time =
            await showTimePicker(context: context, initialTime: TimeOfDay.now());
        if (time == null) return;
    
        setState(() {
          if (isEnd) {
            _endTimeControl.text = DateFormat('yyyy-MM-dd HH:mm')
                .format(date.copyWith(hour: time.hour, minute: time.minute));
          } else {
            _startTimeControl.text = DateFormat('yyyy-MM-dd HH:mm')
                .format(date.copyWith(hour: time.hour, minute: time.minute));
          }
        });
      }
    }
    
    Future openTodoFromDialog(BuildContext context,
        [Todo? data, bool isEdit = false]) {
      return showDialog(
          context: context,
          builder: (context) {
            return Dialog.fullscreen(
              child: TodoForm(data: data, isEdit: isEdit),
            );
          });
    }

    运行效果

    Contents

    • 前言
    • 模型定义
    • 代码实现
    • 模型
    • 模型定义
    • 生成模型
    • 全局变量
    • 共享状态
    • 根节点
    • 维护路由
    • 引入Iconfont
    • 通用组件
    • 界面
    • 表单
    • 运行效果

    2024/03/11 12:59
    image
    image
    image
    image
    image
    image