diff --git a/.gitignore b/.gitignore index 0dc6e5d..af6bf99 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,7 @@ ios **/_old_readme/* **/_self*/ +**/_self* +**/mock*/ +**/bak_*/ +**/bak_* diff --git a/README.md b/README.md index 8544879..33fcadf 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,213 @@ -# free_brief_accounting + + + -极度简单的支出流水账记录…… +- [AI Light Life](#ai-light-life) + - [项目说明](#项目说明) + - [版本说明](#版本说明) + - [创建原因](#创建原因) + - [功能详述](#功能详述) + - [Part1: 智能助手 agi\_llm\_sample](#part1-智能助手-agi_llm_sample) + - [Part2: 极简记账 brief\_accounting](#part2-极简记账-brief_accounting) + - [Part3: 随机菜品 random\_dish](#part3-随机菜品-random_dish) + - [使用说明](#使用说明) + - [导入的菜品 json 文件格式示例](#导入的菜品-json-文件格式示例) + - [Part4: 用户配置 user\_and\_settings](#part4-用户配置-user_and_settings) + - [其他说明](#其他说明) + - [关键文件缺失](#关键文件缺失) + - [开发环境](#开发环境) + - [仅 Android](#仅-android) + - [TODO](#todo) -## Getting Started + -虽然名字是记账,但实际上就是一些流水账(laundry list) +# AI Light Life -### 核心想法 +## 项目说明 -理论上: +**一个包含极简的记账、幸运转盘随机菜品,和简单的 AI 对话、文生图、图像理解等功能的 flutter 应用**。 -- 消费和收入的分类应该有非常专业详细的数据,在后续的报表时肯定用得上。 -- 多账本、预算、循环记账等等比较专业的东西,不会,所以没有。 -- 什么其他账单的关联(微信、支付宝什么的),就天方夜谭了。 +### 版本说明 -所以,流水账,非常简单,sqlite 的 DDL: +- 2024-06-27 `v0.1.0-beta.1` -```sql -CREATE TABLE "income_list" ( - "uuid" INTEGER NOT NULL, - "date" TEXT, - "category" TEXT, - "item" TEXT, - "value" REAL, - PRIMARY KEY("uuid" AUTOINCREMENT) -); + - 基本完成了极简记账的核心功能; + - 基本完成了极简 AI 文本对话的核心功能; + - “文本对话-官方免费”部分整合了百度、腾讯、阿里几个免费使用的大模型 API; + - “文本对话-单个配置”需要自行配置百度、腾讯、阿里的应用 ID 和 KEY,使用自己的付费 API; + - “文本生图-通义万相”需要自行配置阿里云百炼平台的应用 ID 和 KEY; + - “图像理解-Fuyu8B”使用百度千帆平台的 Fuyu-8B 在线服务,需要自行配置百度千帆平台的应用 ID 和 KEY; + - 添加幸运转盘获取随机菜品的功能; + - 用户设置页面中可将应用内部数据完全的备份还原。 -CREATE TABLE "expand_list" ( - "uuid" INTEGER, - "date" TEXT, - "category" TEXT, - "item" TEXT, - "value" REAL, - PRIMARY KEY("uuid" AUTOINCREMENT) -); +### 创建原因 + +- 一开始只是单纯想开发一个极其简单的自用本地记账的 app。 +- **但 2024 年 5 月底国内大模型开启价格战,有了一大批免费使用的 API**,就临时继续在该项目上测试一下效果。 +- 后来发现经常问答有点帮助,就修改了项目定位,改为生活助手类,并整合了之前写好的随机菜品的功能。 + - 随机菜品功能,可以随机生成一个菜品,并给出菜品的详细介绍。为了解决整天不知道吃什么的问题。 +- 后续可能会再添加一些自己需要的小功能,逐步形成一个完整的**可能带有 AI 功能的生活助手应用**。 + +## 功能详述 + +### Part1: 智能助手 agi_llm_sample + +仅仅简单调用百度、腾讯、阿里平台的部分大模型 API,具体功能还在思考当中。 + +![智能助手](./_md_pics/智能助手.jpg) + +- 图 1:目前有考虑文本对话、文生图、图生文的试用; +- 图 2:“文本对话-官方免费”部分整合了百度、腾讯、阿里几个免费使用的大模型 API; + - 3 个平台五六个模型可切换使用; + - 图 3:右上角“最近”会保留最近的对话记录,存入本地的 sqlite 中; +- 图 4:“文本对话-单个配置”需要自行配置百度、腾讯、阿里的应用 ID 和 KEY(见图 8),使用自己的付费 API; + - 就算使用自己付费的模型,模型 API 目前我还没有兼容几个; +- 图 5:“文本生图-通义万相”使用阿里云的通用万相绘制图片,也会留存最近的文生图记录; + - 文本生图需要自行配置阿里云百炼平台的应用 ID 和 KEY; + - 图 6:可以点击进行预览,长按进行保存; + - 生成的图片默认放在阿里云的 OSS,过期时间为 1 天; +- 图 7:“图像理解-Fuyu8B”可以对传入的图片进行理解,并回复相关问题,英文更佳; + - 使用百度千帆平台的 Fuyu-8B 在线服务,需要自行配置百度千帆平台的应用 ID 和 KEY; +- 图 8:在“智能助手”首页右上角可以一次性配置某个平台的应用 ID 和 KEY,需要相关平台的功能才能使用。 + +### Part2: 极简记账 brief_accounting + +极度简单的支出流水账记录,虽然名字是记账,但实际上就是一些流水账,然后简单的统计图表。 + +![极简记账](./_md_pics/极简记账.jpg) + +- 图 1:每天的支出收入信息。 + - 默认是显示当前月份的数据,**上下滚动会切换月份**。 + - 右上角“搜索”按钮,可以对所有的记录进行**关键字搜索**,可以看到相关关键字条目的记录,但不会进行相关统计。 + - **_长按_** 主页的收支记录项次,可以对其进行**删除**。 + - **_双击_** 主页的收支记录项次,可以对其进行**修改**。 +- 图 2:点击右上角“加号”按钮,可以**添加**一条新的支出记录。 +- 图 3 和图 4:点击右上角“条状图”按钮,可以进入统计图表页面。 + - 目前仅支持简单的按月和按年的柱状图和分类饼图显示。 + - 点击月份和年份下拉按钮,可以切换月份和年份。 + +### Part3: 随机菜品 random_dish + +_这个其实是之前(2024-04-09)就单独开发好的 app 了,功能融合,就直接复制到这里来。_ + +给不知道每天吃什么的选择困难症患者,指一条参考选项:随机选择一道菜。 + +如果你关于吃什么,已经习惯了:**随便、不知道、好麻烦、你做主、看运气** 等说法,不妨试一试。 + +当然,最后是点外卖还是自己做甚至选了依旧不吃,还是看自己的决定。 + +#### 使用说明 + +如下图: + +- 主体是一个转盘,可以选择餐次和重新生成随机菜品。 +- 点击转盘即可开始旋转,3 秒后停止,显示结果,旋转时按钮都不可点击。 +- 点击选中结果可以跳转到该菜品详情页。 +- 如果菜品详情有视频地址,可以打开对应 url;如果菜谱有上传图片(仅支持单张本地图片和使用相机拍照),可以缩放查看。 + +![screenshot_1](./_md_pics/screenshot_1.jpg) + +- 当然核心还是菜品的数量,默认是文字列表显示,仅仅为了节约流量。 +- 点击上方“grid”图标(第一个)可以切换到有预览图的卡片列表,如果图片大注意流量消耗。 +- 在列表中点击某一个可以进入详情页(如上),长按可以删除指定菜品。 +- 点击上方“upload”图标(第二个)可以导入菜品 json 文件(格式见下面相关内容,其中图片时本地图片的地址则暂未考虑)。 +- 当然,也可以自行一个个手动添加菜品。 + +![screenshot_2](./_md_pics/screenshot_2.jpg) + +#### 导入的菜品 json 文件格式示例 + +```json +[ + { + "dish_name": "回锅肉", + "description": "此菜色味俱佳,肉鲜而香,是四川省家喻户晓的传统菜,地方风味很强。", + "tags": "川菜,家常菜,肉菜,麻辣鲜香", + "meal_categories": "午餐,晚餐,夜宵", + "images": [ + "http://www.djy.gov.cn/dyjgb_rmzfwz/uploads/20191014154045sde1q1ajz3d.jpg", + "https://i3.meishichina.com/atta/recipe/2019/04/18/20190418155556766674398811368081.jpg?x-oss-process=style/p800" + ], + "videos": ["https://www.bilibili.com/video/BV1eA4m1L7QY/"], + "recipe": [ + "原料:\n猪肉500克,蒜苗150克,化猪油40克,盐1克,郫县豆瓣50克,甜酱25克,红白酱油25克,生姜15克,葱20克,花椒10余粒。", + "作法:\n1. 把带皮的肥瘦相连的猪肉洗干净。", + "2. 锅内放开水置旺火上,下猪肉和葱、姜、花椒;将熟肉煮熟不煮𤆵;在煮肉过程中撇去汤面浮沫。蒜苗洗净切2.6厘米(约八分)长节。豆瓣剁细。", + "3. 将捞起的猪肉敞干水汽,在还有余热时切成约0.3厘米(约一分)厚的连皮肉片。", + "4. 炒锅置中火上,放入猪肉,油烧至五成热时下肉片,同事放微量盐炒均匀;炒至肉片出油时铲在锅边,相继放豆瓣、甜酱在油中炒出香味即与肉共同炒匀,然后放蒜苗合炒;蒜苗炒熟但不要炒蔫,再放酱油炒匀起锅即成。", + "附 注:\n1.在肉汤中加适量新鲜蔬菜同煮,可增加一样汤菜。", + "2.根据爱好,菜内可加豆豉炒。", + "3.如无红酱油可用白糖代替。" + ], + "recipe_picture": "https://demo.image.com" // 菜谱只支持单张图片 + }, + { …… } +] ``` -目前设想的功能: +后续我也会分享一下自用的菜品列表 json 文件,可以一起试用完善。 + +### Part4: 用户配置 user_and_settings + +目前其实没有用户这个概念,除了调用 API 和一些网络图片,都没有需要联网的东西。 + +这个模块目前仅有一个"备份恢复"功能。 + +因为智能助手的对话记录、极简记账的账单条目、随机菜品的菜品列表,都是本地 sqlite 存储的,所以备份就是把 db 中的数据导出成压缩包,恢复就是把压缩包的 json 存入数据库中。 + +哦,那个更换头像纯粹自娱自乐。 + +## 其他说明 + +### 关键文件缺失 + +注意,项目中应该有一个`lib/apis/_self_keys.dart`文件,如果要自己运行项目的话注意补上。 + +内容如下,为 BAT 平台的应用 ID 和 KEY: + +```dart +// ignore_for_file: constant_identifier_names +/// 百度的相关 +const BAIDU_API_KEY = "xxx"; +const BAIDU_SECRET_KEY = "xxx"; +/// 腾讯的相关 +const TENCENT_SECRET_ID = "xxx"; +const TENCENT_SECRET_KEY = "xxx"; +/// 阿里云的相关 +const ALIYUN_API_KEY = "xxx"; +const ALIYUN_APP_ID = "xxx"; +``` + +### 开发环境 + +在一个 Windows7 中使用 Visual Box 7 安装的 Ubuntu20.04 LTS 虚拟机中使用 VSCode 进行开发。 + +2024-05-27 使用最新 flutter 版本: + +```sh +$ flutter --version +Flutter 3.22.1 • channel stable • https://github.com/flutter/flutter.git +Framework • revision a14f74ff3a (4 天前) • 2024-05-22 11:08:21 -0500 +Engine • revision 55eae6864b +Tools • Dart 3.4.1 • DevTools 2.34.3 +``` + +### 仅 Android + +**手里没有 IOS 等其他设备,所以相关内容为 0**。 + +虽然我日常主力机为努比亚 Z60 Ultra(Android 14),但它和我之前的 Z50 Ultra 一样,[**无法实机开发调试**](https://github.com/flutter/flutter/issues/144999),所以只有最后打包成 app 后进行使用测试。 -- 流水账(支出列表显示)的显示和记录(全手动输入的简单表单) -- 收入/支出 item 的关键字查询 -- 报表仅仅按月、按年显示相关数据 - - (核心目的就是单纯想之前 python 编写的脚本绘制的柱状图在 app 显示罢了) +而第二主力机是小米 6(Android 9),是 16:9 的 1080P 完整屏幕,和目前主流手记的分辨率和长 K 宽比都不一样,几乎肯定在其他的设备有一些显示上的差距,可以反馈或自行修改。 -### 页面设计 +根据模型设计的思想,可以看随手记的 [开发记录](_md_pics/development_records.md) 文档。 -就 3 个页面 +### TODO -- 主页面:列表显示每条支出/收入的数据 - - (测试数据的工资收入默认就每月 1 号到账了) -- 新增页面:非常简单的一个表单填写数据 -- 报表页面:几个预设的简单图表 -- 导入/导出:因为是 app 内置的 sqlite 中,所以导出备份比较重要 - - 方便导入导出,都 json 格式好了,不要什么 excel、pdf 之类的了,不好处理。 +- 因为自用,事前没有完整的项目规划,所以代码质量比较差。 + - 许多同样逻辑的代码但写得五花八门,很多没用的组件代码块还保留,注释的代码还保留,甚至 print 都还保留。 +- 智能对话 + - 通义千问-VL 调用 API 未符合预期 +- 一开始就只是想试一下百度的两个免费的模型 API,没有对平台和模型进行兼容性设计。 + - 目前提供免费 token 的大模型和云平台有很多,这一块会继续完善。 +- 其实目前大模型 token 都很便宜,但是具体能用来做什么,真的不知道。 diff --git a/_md_pics/development_records.md b/_md_pics/development_records.md new file mode 100644 index 0000000..1e4887c --- /dev/null +++ b/_md_pics/development_records.md @@ -0,0 +1,424 @@ +# AI Light Life + +- [AI Light Life](#ai-light-life) + - [说明](#说明) + - [项目版本](#项目版本) + - [项目依赖](#项目依赖) + - [Part1: 记账部分 brief\_accounting](#part1-记账部分-brief_accounting) + - [核心想法](#核心想法) + - [页面设计](#页面设计) + - [Part2: AI 大模型部分 agi\_llm\_sample](#part2-ai-大模型部分-agi_llm_sample) + - [核心想法](#核心想法-1) + - [页面设计](#页面设计-1) + - [Part3: 随机菜品 random\_dish](#part3-随机菜品-random_dish) + - [使用说明](#使用说明) + - [设计简述](#设计简述) + - [导入的菜品 json 文件格式示例](#导入的菜品-json-文件格式示例) + - [开发过程](#开发过程) + - [开发记录](#开发记录) + - [TODO](#todo) + +## 说明 + +一些我自己可能常常用的到的小功能,比如记账、菜谱、AI chat 等等 + +一开始单纯想做个适合自己的非常简陋的记账 app,2024-05-30 添加了一个基于免费的大模型 AI 对话,应用名就不好说了: + +- 2024-05-30: Cost and AI Chat (AI 聊天和极简记账(智能对话和记账)) +- 2024-06-14: Light Life (简单生活?) +- 2024-06-17: AI Light Life (智能轻生活?) + +## 项目版本 + +- `0.1.x` + - 基本完成了极简记账的核心功能; +- `0.2.x` + - 基本完成了极简 AI 对话的核心功能; +- `0.3.x` + - AI 对话部分整合了百度、腾讯、阿里几个免费使用的大模型 API,可简单切换; + - 基本可简单使用阿里的通义万相来文本生成图片; + - 基本可简单使用阿里云平台的 Fuyu-8B 模型来完成图像理解功能; +- `0.4.x` + - 添加幸运转盘获取随机菜品的功能; + - `0.4.1` + - 添加了可自行配置三个平台的部分付费模型(使用用户自己的 appId 和 appKey) + - `0.4.2` + - 阿里云的视觉模型测试试用(没实现,http 调用和 API 文档一样都无法使用) + +## 项目依赖 + +2024-05-27 使用最新 flutter 版本: + +```sh +$ flutter --version +Flutter 3.22.1 • channel stable • https://github.com/flutter/flutter.git +Framework • revision a14f74ff3a (4 天前) • 2024-05-22 11:08:21 -0500 +Engine • revision 55eae6864b +Tools • Dart 3.4.1 • DevTools 2.34.3 +``` + +依赖工具库: + +注意,我个人是想着尽可能多的学习使用 flutter 的各项库,所以可能这些依赖并不一定是核心功能必要的。 + +截止 2024-06-27,使用当前可用的最新版本。 + +```yaml +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + sqflite: ^2.3.3+1 # sqlite数据库工具库 + path_provider: ^2.1.3 # 获取主机平台文件系统上的常用位置 + path: ^1.9.0 # 基于字符串的路径操作库 + flutter_easyloading: ^3.0.5 # loading/toast 小部件 + flutter_screenutil: ^5.9.3 # 适配屏幕和字体大小的插件 + intl: ^0.19.0 # 国际化/本地化处理库 + flutter_localizations: + sdk: flutter + # collection: ^1.19.0 #1.19和flutter_test有冲突 + collection: ^1.18.0 # 集合相关的适用工具库 + bottom_picker: ^2.8.0 # 简洁,但不支持仅年月 + month_picker_dialog: ^4.0.0 # 支持仅年月,但是是弹窗,和原始组件类似 + syncfusion_flutter_charts: ^26.1.39 # 图表库 + flutter_form_builder: ^9.3.0 # 表单组件 + form_builder_validators: ^10.0.1 # 表单验证 + form_builder_file_picker: ^4.1.0 # 表单中选择文件 + uuid: ^4.4.0 # uuid + flutter_markdown: ^0.7.2+1 # 使用md格式显示大模型的响应 + dio: ^5.4.3+1 # http client # http client + connectivity_plus: ^6.0.3 # 用于发现可以使用的网络连接类型 + pretty_dio_logger: ^1.3.1 # Dio 拦截器,它以漂亮、易于阅读的格式记录网络调用。 + crypto: ^3.0.3 # Dart 的一组加密哈希函数。 + # file_picker: ^8.0.3 # 备份恢复是选择文件路径(v8版本和上面表单中选择文件的库有冲突) + file_picker: ^5.5.0 + permission_handler: ^11.3.1 # 获取设备各项权限 + archive: ^3.6.1 # 解压缩文件 + device_info_plus: ^10.1.0 # 获取设备信息 + # animated_text_kit: ^4.2.2 # 动画文本特效工具,上次更新2022-06-05 但用户多 + toggle_switch: ^2.3.0 # 第三方的切换按钮 + cached_network_image: ^3.3.1 # 缓存网络图片 + image_gallery_saver: ^2.0.3 # 保存图片到图库(安卓9及以下无效) + photo_view: ^0.15.0 # 图片预览 + url_launcher: ^6.3.0 # 打开url + carousel_slider: ^4.2.1 # 轮播滑块小部件 + multi_select_flutter: ^4.1.3 # 一个用于以多种方式创建多选小部件的包 + image_picker: ^1.1.2 # 从设备选图片或者拍照 + flutter_fortune_wheel: ^1.3.1 # 幸运大转盘 + get_storage: ^2.1.1 # 简单键值对本地存储 +``` + + + +## Part1: 记账部分 brief_accounting + +极度简单的支出流水账记录…… + +虽然名字是记账,但实际上就是一些流水账(laundry list) + +### 核心想法 + +记账,“账”虽然是重点,但也不应该忽视“记”这个动作,如果全都自动化了,岂不是"强化数字消费中的失去感"。 + +还是觉得手动记账,一笔一笔在输入时才能体会的金钱的份量、和记账的意义。 + +理论上: + +- 消费和收入的分类应该有非常专业详细的数据,在后续的报表时肯定用得上。 +- 多账本、预算、循环记账等等比较专业的东西,不会,所以没有。 +- 什么其他账单的关联(微信、支付宝什么的),就天方夜谭了。 + +所以,流水账,非常简单,sqlite 的 DDL: + +```sql +CREATE TABLE "income_list" ( + "income_id" INTEGER NOT NULL, + "date" TEXT, + "category" TEXT, + "item" TEXT, + "value" REAL, + PRIMARY KEY("income_id" AUTOINCREMENT) +); + +CREATE TABLE "expend_list" ( + "expend_id" INTEGER, + "date" TEXT, + "category" TEXT, + "item" TEXT, + "value" REAL, + PRIMARY KEY("expend_id" AUTOINCREMENT) +); +``` + +=> 再简单一点,只有一个表,用 `type` 表示收入和支出; +再加一个 `gmt_modified` ,排序时联合 `date` 栏位共同排序避免和实际新增记录时不一致: + +```sql +CREATE TABLE "bill_item_list" ( + "bill_item_id" TEXT, -- 账单条目编号(万一多账本有bill_id呢) + "item_type" INTEGER, -- 0 收入;1 支出 + "date" TEXT, -- yyyy-MM-dd 年月日即可 + "category" TEXT, -- 支出或收入的大分类(比如支出的:通勤、医疗、饮食……) + "item" TEXT, -- 支出或收入的细项目(比如饮食的晚餐吃快餐) 还可以有细节就再多个detail表 + "value" REAL, -- 支出或收入的具体数值 + "gmt_modified" TEXT, -- 记录的创建或者修改时间 + PRIMARY KEY("bill_item_id") +); +``` + +目前设想的功能: + +- 流水账(支出列表显示)的显示和记录(全手动输入的简单表单) +- 收入/支出 item 的关键字查询 +- 报表仅仅按月、按年显示相关数据 + - (核心目的就是单纯想之前 python 编写的脚本绘制的柱状图在 app 显示罢了) + +### 页面设计 + +就 3 个页面 + +- 主页面:列表显示每条支出/收入的数据 + - (测试数据的工资收入默认就每月 1 号到账了) +- 新增页面:非常简单的一个表单填写数据 +- 报表页面:几个预设的简单图表 +- 导入/导出:因为是 app 内置的 sqlite 中,所以导出备份比较重要 + - 方便导入导出,都 json 格式好了,不要什么 excel、pdf 之类的了,不好处理。 + +## Part2: AI 大模型部分 agi_llm_sample + +### 核心想法 + +其实就是一些免费使用的国内大模型 API 的简单调用,查看效果而已。 + +用户和 AI 进行对话后,为了能查询历史记录,就把对话内容存入 sqlite,基本表结构: + +```sql +-- 2024-06-01 新增AI对话留存 +-- 图像理解也有对话,所以新加一个对话类型栏位:aigc、image2text、text2image…… +-- i2t_image_path 指图像理解时被参考的图片地址(应该是应用缓存的图片地址) +CREATE TABLE "chat_history" ( + uuid TEXT NOT NULL, + title TEXT NOT NULL, + gmt_create TEXT NOT NULL, + messages TEXT NOT NULL, + llm_name TEXT NOT NULL, + yun_platform_name TEXT, + i2t_image_path TEXT, + chat_type TEXT NOT NULL, + PRIMARY KEY("uuid") +); + +-- 2024-06-13 新增文生图简单内容流程 +CREATE TABLE "text2image_history" ( + request_id TEXT NOT NULL, + prompt TEXT NOT NULL, + negative_prompt TEXT, + style TEXT NOT NULL, + image_urls TEXT, + gmt_create TEXT NOT NULL, + PRIMARY KEY("request_id") +); +``` + +### 页面设计 + +就两层:外层提供文生文、文生图、图生文的选项,内层显示对应的对话列表即可。 + +## Part3: 随机菜品 random_dish + +_这个其实是之前(2024-04-09)就单独开发好的 app 了,功能融合,就直接复制到这里来。_ + +给不知道每天吃什么的选择困难症患者,指一条参考选项:随机选择一道菜。 + +如果你关于吃什么,已经习惯了:**随便、不知道、好麻烦、你做主、看运气** 等说法,不妨试一试。 + +当然,最后是点外卖还是自己做甚至选了依旧不吃,还是看自己的决定。 + +### 使用说明 + +如下图: + +- 主体是一个转盘,可以选择餐次和重新生成随机菜品。 +- 点击转盘即可开始旋转,3 秒后停止,显示结果,旋转时按钮都不可点击。 +- 点击选中结果可以跳转到该菜品详情页。 +- 如果菜品详情有视频地址,可以打开对应 url;如果菜谱有上传图片(仅支持单张本地图片和使用相机拍照),可以缩放查看。 + +![screenshot_1](./screenshot_1.jpg) + +- 当然核心还是菜品的数量,默认是文字列表显示,仅仅为了节约流量。 +- 点击上方“grid”图标(第一个)可以切换到有预览图的卡片列表,如果图片大注意流量消耗。 +- 在列表中点击某一个可以进入详情页(如上),长按可以删除指定菜品。 +- 点击上方“upload”图标(第二个)可以导入菜品 json 文件(格式见下面相关内容,其中图片时本地图片的地址则暂未考虑)。 +- 当然,也可以自行一个个手动添加菜品。 + +![screenshot_2](./screenshot_2.jpg) + +### 设计简述 + +1. 主体只有简单的一张菜品表: + ```sql + CREATE TABLE "dish" ( + dish_id TEXT NOT NULL PRIMARY KEY, + dish_name TEXT NOT NULL, + description TEXT, + photos TEXT, + videos TEXT, + tags TEXT, + meal_categories TEXT, + recipe TEXT, + recipe_picture TEXT, + UNIQUE(dish_name,tags) + ); + ``` +2. 把一天分成几个时间段,打开 app 时,显示该时间段的随机 10 个菜品 + 1. 如果不满意,可以切换时间段和重新随机 10 个菜品 +3. 点击转盘开始旋转,3 秒后选中某个菜品。 + 1. 点击该菜品,进入菜品详情,查看图片和菜谱等信息。 +4. 可以自行维护菜品列表,导入规范的 json 文件,或者自行添加菜品。 + 1. json 文件格式参看下面,注意**导入的 tags 和 meal_categories 不在预设中,有修改后则不会显示** + 2. 具体 tags,比如凉菜、汤菜、煎、炒、烹、炸、焖、溜、熬、炖、汆等 + 3. 具体 meal_categories,比如 早餐、晚餐、午餐、夜宵、甜点、主食等 +5. TODO(不一定会做): + 1. 为了随机的乐观性,可以多一张 random_record 随机记录表。 + 2. 某个时间段随机过了,就不允许再随机了。 + 3. 可以查看随机过的菜品记录。 + 4. …… + ```txt random_record + random_record_id + date + meal_category + dish_id + …… + ``` + +### 导入的菜品 json 文件格式示例 + +```json +[ + { + "dish_name": "回锅肉", + "description": "此菜色味俱佳,肉鲜而香,是四川省家喻户晓的传统菜,地方风味很强。", + "tags": "川菜,家常菜,肉菜,麻辣鲜香", + "meal_categories": "午餐,晚餐,夜宵", + "images": [ + "http://www.djy.gov.cn/dyjgb_rmzfwz/uploads/20191014154045sde1q1ajz3d.jpg", + "https://i3.meishichina.com/atta/recipe/2019/04/18/20190418155556766674398811368081.jpg?x-oss-process=style/p800" + ], + "videos": ["https://www.bilibili.com/video/BV1eA4m1L7QY/"], + "recipe": [ + "原料:\n猪肉500克,蒜苗150克,化猪油40克,盐1克,郫县豆瓣50克,甜酱25克,红白酱油25克,生姜15克,葱20克,花椒10余粒。", + "作法:\n1. 把带皮的肥瘦相连的猪肉洗干净。", + "2. 锅内放开水置旺火上,下猪肉和葱、姜、花椒;将熟肉煮熟不煮𤆵;在煮肉过程中撇去汤面浮沫。蒜苗洗净切2.6厘米(约八分)长节。豆瓣剁细。", + "3. 将捞起的猪肉敞干水汽,在还有余热时切成约0.3厘米(约一分)厚的连皮肉片。", + "4. 炒锅置中火上,放入猪肉,油烧至五成热时下肉片,同事放微量盐炒均匀;炒至肉片出油时铲在锅边,相继放豆瓣、甜酱在油中炒出香味即与肉共同炒匀,然后放蒜苗合炒;蒜苗炒熟但不要炒蔫,再放酱油炒匀起锅即成。", + "附 注:\n1.在肉汤中加适量新鲜蔬菜同煮,可增加一样汤菜。", + "2.根据爱好,菜内可加豆豉炒。", + "3.如无红酱油可用白糖代替。" + ], + "recipe_picture": "https://demo.image.com" // 菜谱只支持单张图片 + }, + { …… } +] +``` + +## 开发过程 + +### 开发记录 + +直接在 widget 上用的函数就不加下划线了,如果在其他函数中用的,就加下划线前缀。 + +- 2024-05-24 + - 基本完成账单列表首页的基础功能和组件占位. +- 2024-05-26 + - 基本完成月度年度统计基础柱状图展示和相关组件的占位 +- 2024-04-27 + - feat: 完成账单条目的新增和修改功能; chore: 升级 flutter 为 3.22.1, 相关依赖库为当前最新. +- 2024-05-28 + - 账单条目关键字查询时切换到新的列表展示; feat: 完成带月统计值的列表展示在有多个月数据时滚动到某个月就加载某个月的总计. + - perf: 优化了图表页面的相关方法,重复度高的代码抽成了公共组件或方法。 + - perf: 优化了账单项目列表主页面、编辑项次表单页面的相关方法和细节。 +- 2024-05-29 + - 修正了一些细节;将新增账单项次放到账单列表页面中,调整账单项次表单页面;添加分类选择底部弹窗组件(暂未用到)。 + - 重新调整了账单管理模块的结构,新增 AGI LLM 模块用于对话的组件框架。 +- 2024-05-30 + - feat: 调价了基于 dio 的通用 http client 工具类;构建了 Ernie 和 huanyuan 模型的 model 以及基础查询函数。 +- 2024-05-31 + - feat: 基本可以正常使用大模型进行对话了。 + - fix: 优化了 AI 对话框的显示细节,完善了一些其他功能细节。 +- 2024-06-03 + - feat: 添加了保存 AI 对话记录到本地数据库,可重新读取并继续提问。 +- 2024-06-04 + - feat:添加了内部 sqlite 数据的备份恢复功能。 + - feat: 账单统计部分添加了分类统计甜甜圈图。 + - fix: 修正高版本的 Android 请求内部存储的授权问题。 +- 2024-06-05 + - fix: 再度修正备份时内部存储管理权限的授权问题,Android14 已正常。 + - feat: 最后一个大模型回复不满意可以点击重新生成;在 appbar 添加“新建对话”按钮;调整标题固定显示在对话正文顶部,点击修改图标可修改。 +- 2024-06-06 + - refactor: 添加了阿里云、腾讯、百度通用的基础 aigc 的 http 请求和响应对应的 model; feat: 阿里云通义千问开源版本一个模型接口测试可用。 +- 2024-06-07 + - refactor: 重构了通用 aigc 请求 api 和 model 的使用,整理了对应文件命名和文件夹结构等。 + - fix: 简单调整了 llm 名称变量命令、对话主页面的入口按钮样式等。 +- 2024-06-11 + - feat: 添加了 AI 对话的流式请求响应的处理; fix:api 还是拆回 3 个平台各自一个文件。 + - fix: 将云平台切换、大模型切换、文生文的流式请求、最近对话统一放在了 AI 对话页面中。 +- 2024-06-12 + - fix: AI 对话页面删除历史对话为当前对话时,返回初始对话状态;点击历史对话会替换当前对话而不是新开页面;其他显示细节。 + - feat: 基本完成了阿里的通义万相-文本生成图像的基础使用页面。 +- 2024-06-13 + - fix: 调整了通义万相-文本生图页面的细节,修正了循环检查任务状态的逻辑。 + - feat: 新加通义万相-文本生图的简单历史记录留存,简单数据保持到本地数据库。 +- 2024-06-14 + - feat: 基本完成了百度云中 Fuyu-8B 图像理解的基础使用页面。 + - 【2024-06-21 更换了选择图片的库】**bug**:每次选择了图片用于上传做理解后,都会在本地生成一个副本图片,传一次就多一张,原因还没看。 + - feat: 添加了 Fuyu-8B 图像理解对话记录存到本地数据库。 + - fix: 简单调整了文本对话、文生图、图生文各自主页面的一些细节,删除部分无意义代码。 +- 2024-06-15 + - feat: 添加了 AI 对话限时限量测试版本的基础页面。 + - 【done】todo: 切换模型应该新建对话,因为上下文丢失了。 + - feat: 添加了阿里云中限时限量的对话模型可选列表。 +- 2024-06-17 + - feat: 合并了之前的 flutter_random_dish 项目的功能。 + - todo: 数据库文件还是分开的,要何在一起吗? + - doc: 更新 readme 文件。 + - rename: 重命名了整个项目名称。 +- 2024-06-20 + - feat: 添加了支持阿里、百度、腾讯云平台部分付费模型自行配置 appId 和 appKey 的功能。 + - refactor: 将免费对话、限量对话和自定配置对话的聊天页面统一成一个页面,根据参数进行复用。 + - fix:简化三个平台对话接口的响应函数,并添加错误处理。 +- 2024-06-21 + - feat: 尝试添加通义千问-VL 的示例 + - !!!参数和 API 文档一样,但是报错: + ``` + response: { + "code":"InvalidParameter", + "message":"The item of `content` should be a message of a certain modal", + "request_id":"03a79c8b-66dd-9859-a11a-2667f1dffcbb" + } + ``` + - 注意:不允许本地图片地址,但测试报错,所以也没有实现上传到云端后再使用网络图片地址调用接口的逻辑。 + - fix: 添加 baidu 的 Fuyu8B 请求错误处理,修改添加图片的函数。 +- 2024-06-22 + - feat: 用户可以添加 3 个平台自己的通用应用 ID 和 KEY 来使用部分模型功能。 + - todo: 对大模型的功能入口划分不够细致 +- 2024-06-24 + - fix: 平台大模型的 api 请求加上错误处理;用户自行配置的平台应用 ID 和 KEY 与单个对话模型的配置合并,减少冗余;修正部分显示错误和细节。 + - fix: 修正部分细节。 +- 2024-06-26 + - fix: 随机菜品部分,加上所有预设分类和导入时存入 db 中的分类。 +- 2024-06-27 + - fix: 修正菜品编辑时图片显示错误;修正 Android14 上获取存储权限异常问题;refactor:合并 dish 的数据库内容到唯一内嵌的 db 中。 + - feat: 添加“用户设置”示例页面,放置全量备份恢复。fix:随机菜品下拉分类部分不仅是预设也包含数据库中导入时使用的其他分类。 + - fix: 修复 Z60U 中文本生图中点击历史记录无法显示弹窗的问题;Z60U 在账单年度统计切换年时无法显示弹窗的问题;doc: 更新 readme 文件。 + +### TODO + +- 智能对话 + - 通义千问-VL 调用 API 未符合预期 + - 文本对话使用 `flutter_markdown:0.7.2` 库长按会报错 + - `0.7.2+1` 版本好像修复了 + +flutter build apk --split-per-abi diff --git a/_md_pics/screenshot_1.jpg b/_md_pics/screenshot_1.jpg new file mode 100755 index 0000000..ee045c6 Binary files /dev/null and b/_md_pics/screenshot_1.jpg differ diff --git a/_md_pics/screenshot_2.jpg b/_md_pics/screenshot_2.jpg new file mode 100755 index 0000000..adb522e Binary files /dev/null and b/_md_pics/screenshot_2.jpg differ diff --git "a/_md_pics/\346\231\272\350\203\275\345\212\251\346\211\213.jpg" "b/_md_pics/\346\231\272\350\203\275\345\212\251\346\211\213.jpg" new file mode 100755 index 0000000..f5b94b7 Binary files /dev/null and "b/_md_pics/\346\231\272\350\203\275\345\212\251\346\211\213.jpg" differ diff --git "a/_md_pics/\346\236\201\347\256\200\350\256\260\350\264\246.jpg" "b/_md_pics/\346\236\201\347\256\200\350\256\260\350\264\246.jpg" new file mode 100755 index 0000000..ebceb63 Binary files /dev/null and "b/_md_pics/\346\236\201\347\256\200\350\256\260\350\264\246.jpg" differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 50f33b3..7783899 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -23,8 +23,9 @@ if (flutterVersionName == null) { } android { - namespace "com.swm.free_brief_accounting" - compileSdkVersion flutter.compileSdkVersion + namespace "com.swm.ai_light_life" + // compileSdkVersion flutter.compileSdkVersion + compileSdkVersion 34 ndkVersion flutter.ndkVersion compileOptions { @@ -42,7 +43,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "com.swm.free_brief_accounting" + applicationId "com.swm.ai_light_life" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. minSdkVersion flutter.minSdkVersion diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 27daeb6..6d6eec4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,18 @@ + + + + + + + + + + + - + \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/free_brief_accounting/MainActivity.kt b/android/app/src/main/kotlin/com/example/free_brief_accounting/MainActivity.kt index aa7db61..e66097d 100644 --- a/android/app/src/main/kotlin/com/example/free_brief_accounting/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/free_brief_accounting/MainActivity.kt @@ -1,4 +1,4 @@ -package com.swm.free_brief_accounting +package com.swm.ai_light_life import io.flutter.embedding.android.FlutterActivity diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png old mode 100644 new mode 100755 index db77bb4..b91ea6c Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png old mode 100644 new mode 100755 index 17987b7..b91ea6c Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png old mode 100644 new mode 100755 index 09d4391..b91ea6c Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png old mode 100644 new mode 100755 index d5f1c8d..b91ea6c Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png old mode 100644 new mode 100755 index 4d6372e..b91ea6c Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/gradle.properties b/android/gradle.properties index 598d13f..bb37af6 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,5 @@ org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true +android.enableR8=true +org.gradle.java.home=/home/david/.jdks/temurin-17.0.6 \ No newline at end of file diff --git a/assets/images/no_image.jpg b/assets/images/no_image.jpg new file mode 100644 index 0000000..8987fac Binary files /dev/null and b/assets/images/no_image.jpg differ diff --git "a/assets/text2image_styles/3D\345\215\241\351\200\232.jpg" "b/assets/text2image_styles/3D\345\215\241\351\200\232.jpg" new file mode 100755 index 0000000..2d4d1de Binary files /dev/null and "b/assets/text2image_styles/3D\345\215\241\351\200\232.jpg" differ diff --git "a/assets/text2image_styles/\344\270\255\345\233\275\347\224\273.jpg" "b/assets/text2image_styles/\344\270\255\345\233\275\347\224\273.jpg" new file mode 100755 index 0000000..b1c31d5 Binary files /dev/null and "b/assets/text2image_styles/\344\270\255\345\233\275\347\224\273.jpg" differ diff --git "a/assets/text2image_styles/\345\212\250\347\224\273.jpg" "b/assets/text2image_styles/\345\212\250\347\224\273.jpg" new file mode 100755 index 0000000..f286a28 Binary files /dev/null and "b/assets/text2image_styles/\345\212\250\347\224\273.jpg" differ diff --git "a/assets/text2image_styles/\346\211\201\345\271\263\346\217\222\347\224\273.jpg" "b/assets/text2image_styles/\346\211\201\345\271\263\346\217\222\347\224\273.jpg" new file mode 100755 index 0000000..87e857e Binary files /dev/null and "b/assets/text2image_styles/\346\211\201\345\271\263\346\217\222\347\224\273.jpg" differ diff --git "a/assets/text2image_styles/\346\260\264\345\275\251.jpg" "b/assets/text2image_styles/\346\260\264\345\275\251.jpg" new file mode 100755 index 0000000..4ca705e Binary files /dev/null and "b/assets/text2image_styles/\346\260\264\345\275\251.jpg" differ diff --git "a/assets/text2image_styles/\346\262\271\347\224\273.jpg" "b/assets/text2image_styles/\346\262\271\347\224\273.jpg" new file mode 100755 index 0000000..8d1a792 Binary files /dev/null and "b/assets/text2image_styles/\346\262\271\347\224\273.jpg" differ diff --git "a/assets/text2image_styles/\347\264\240\346\217\217.jpg" "b/assets/text2image_styles/\347\264\240\346\217\217.jpg" new file mode 100755 index 0000000..db8b2e7 Binary files /dev/null and "b/assets/text2image_styles/\347\264\240\346\217\217.jpg" differ diff --git "a/assets/text2image_styles/\351\273\230\350\256\244.jpg" "b/assets/text2image_styles/\351\273\230\350\256\244.jpg" new file mode 100755 index 0000000..7e5f628 Binary files /dev/null and "b/assets/text2image_styles/\351\273\230\350\256\244.jpg" differ diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..7e7e7f6 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1 @@ +extensions: diff --git a/lib/apis/aliyun_apis.dart b/lib/apis/aliyun_apis.dart new file mode 100644 index 0000000..21512b8 --- /dev/null +++ b/lib/apis/aliyun_apis.dart @@ -0,0 +1,256 @@ +// ignore_for_file: avoid_print + +import 'dart:convert'; + +import '../dio_client/cus_http_client.dart'; +import '../dio_client/cus_http_request.dart'; +import '../dio_client/interceptor_error.dart'; +import '../models/ai_interface_state/aliyun_qwenvl_state.dart'; +import '../models/ai_interface_state/aliyun_text2image_state.dart'; +import '../models/common_llm_info.dart'; +import '../services/cus_get_storage.dart'; +import '_self_keys.dart'; + +/// +/// 文生图任务提交 +/// +var aliyunText2imageUrl = + "https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis"; + +Future commitAliyunText2ImgJob( + Input input, + Parameters parameters, +) async { + var body = AliyunTextToImgReq( + model: "wanx-v1", + input: input, + parameters: parameters, + ); + + try { + var start = DateTime.now().millisecondsSinceEpoch; + + var respData = await HttpUtils.post( + path: aliyunText2imageUrl, + method: HttpMethod.post, + headers: { + "X-DashScope-Async": "enable", // 固定的,异步方式提交作业。 + "Content-Type": "application/json", + // 检查用户通用配置;再才是自己的账号key + "Authorization": + "Bearer ${MyGetStorage().getAliyunCommonAppKey() ?? ALIYUN_API_KEY}", + }, + // 可能是因为头的content type设定,这里直接传类实例即可,传toJson也可 + data: body, + ); + + print("阿里云文生图---------------------$respData"); + + var end = DateTime.now().millisecondsSinceEpoch; + + print("2222222222xxxxxxxxxxxxxxxxx${(end - start) / 1000} 秒"); + + ///??? 2024-06-11 阿里云请求报错,会进入dio的错误拦截器,这里ret就是个null了 + if (respData.runtimeType == String) { + respData = json.decode(respData); + } + + // 响应是json格式 + return AliyunTextToImgResp.fromJson(respData ?? {}); + } on HttpException catch (e) { + return AliyunTextToImgResp( + // 这里的code和msg就不是api返回的,是自行定义的,应该抽出来 + code: e.code.toString(), + message: e.msg, + ); + } catch (e) { + print("bbbbbbbbbbbbbbbb ${e.runtimeType}---$e"); + // API请求报错,显示报错信息 + return AliyunTextToImgResp( + // 这里的code和msg就不是api返回的,是自行定义的,应该抽出来 + code: "10001", + message: e.toString(), + ); + } +} + +/// +/// 作业任务状态查询和结果获取接口 +/// GET https://dashscope.aliyuncs.com/api/v1/tasks/{task_id} +/// +Future getAliyunText2ImgJobResult(String taskId) async { + try { + var start = DateTime.now().millisecondsSinceEpoch; + + // var taskId = "8bb11ab3-a7b2-4e37-b90f-f7d31d356279"; + + var respData = await HttpUtils.post( + path: "https://dashscope.aliyuncs.com/api/v1/tasks/$taskId", + method: HttpMethod.get, + headers: { + // 检查用户通用配置;再才是自己的账号key + "Authorization": + "Bearer ${MyGetStorage().getAliyunCommonAppKey() ?? ALIYUN_API_KEY}", + }, + ); + + print("阿里云文生图结果查询---------------------$respData"); + + var end = DateTime.now().millisecondsSinceEpoch; + + print("333333xxxxxxxxxxxxxxxxx${(end - start) / 1000} 秒"); + + ///??? 2024-06-11 阿里云请求报错,会进入dio的错误拦截器,这里ret就是个null了 + if (respData.runtimeType == String) { + respData = json.decode(respData); + } + + // 响应是json格式 + return AliyunTextToImgResp.fromJson(respData ?? {}); + } on HttpException catch (e) { + return AliyunTextToImgResp( + // 这里的code和msg就不是api返回的,是自行定义的,应该抽出来 + code: e.code.toString(), + message: e.msg, + ); + } catch (e) { + print("aaaaaas ${e.runtimeType}---$e"); + // API请求报错,显示报错信息 + return AliyunTextToImgResp( + // 这里的code和msg就不是api返回的,是自行定义的,应该抽出来 + code: "10001", + message: e.toString(), + ); + } +} + +/// +/// 阿里的视觉大模型通义千问-VL 的请求 +/// https://help.aliyun.com/document_detail/2712587.html +/// + +/// 阿里平台通用多模态视觉大模型的请求地址 +var aliyunMultimodalUrl = + "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"; + +Future> getAliyunQwenVLResp( + List messages, { + String? model, + bool stream = false, + bool isUserConfig = true, +}) async { + // 如果有传模型名称,就用传递的;没有就默认的 + model = model ?? newLLMSpecs[PlatformLLM.limitedQwenVLPlus]!.model; + +// ????? +// 2024-06-21 +// 明明和API文档一样了,为什么不行 +// The item of `content` should be a message of a certain modal + var body = AliyunQwenVlReq( + model: model, + input: QwenVLInput(messages: messages), + parameters: + stream ? QwenVLParameters(incrementalOutput: true) : QwenVLParameters(), + ); + + var start = DateTime.now().millisecondsSinceEpoch; + + var header = { + "Content-Type": "application/json", + // 如果是用户自行配置,使用检查用户通用配置;否则是自己的账号key + "Authorization": + "Bearer ${isUserConfig ? MyGetStorage().getAliyunCommonAppKey() : ALIYUN_API_KEY}", + }; + // 如果是流式,开启SSE + if (stream) { + header.addAll({"X-DashScope-SSE": "enable"}); + } + + print("""-------------------------------------- +getAliyunQwenVLResp 的请求体,AliyunQwenVlResp: +${json.encode(body.toSimpleJson(stream))} +-------------------------------------- +"""); + + try { + var respData = await HttpUtils.post( + path: aliyunMultimodalUrl, + method: HttpMethod.post, + headers: header, + // 可能是因为头的content type设定,这里直接传类实例即可,传toJson也可 + data: json.encode(body.toSimpleJson(stream)), + ); + + var end = DateTime.now().millisecondsSinceEpoch; + print("阿里云qwen-vl响应耗时: ${(end - start) / 1000} 秒"); + + if (stream) { + // 使用正则表达式匹配所有以"data:{"开头的字符串 + final regex = RegExp(r'.*data:\{".*}', multiLine: true); + final matches = regex.allMatches(respData); + + // 提取匹配到的字符串并添加到数组中 + List dataArray = []; + for (final match in matches) { + // 替换"data:"为空字符串(看结果data后面的冒号没有空格) + final replacedString = match.group(0)!.replaceAll(RegExp(r'data:'), ''); + dataArray.add(replacedString); + } + + List list = dataArray + .map((e) => AliyunQwenVlResp.fromJson(json.decode(e))) + .toList(); + + print(list); + + return list; + } else { + ///??? 2024-06-11 阿里云请求报错,会进入dio的错误拦截器,这里ret就是个null了 + if (respData.runtimeType == String) { + respData = json.decode(respData); + } + + // 响应是json格式 + return [AliyunQwenVlResp.fromJson(respData ?? {})]; + } + } on HttpException catch (e) { + return [ + AliyunQwenVlResp( + customReplyText: e.toString(), + // 这里的code和msg就不是api返回的,是自行定义的,应该抽出来 + errorCode: e.code.toString(), + errorMsg: e.msg, + ) + ]; + } catch (e) { + print("vvvvvvvvvvvvvvvvvvl ${e.runtimeType}---$e"); + // API请求报错,显示报错信息 + return [ + AliyunQwenVlResp( + customReplyText: e.toString(), + // 这里的code和msg就不是api返回的,是自行定义的,应该抽出来 + errorCode: "10000", + errorMsg: e.toString(), + ) + ]; + } +} + +var a = { + "model": "qwen-vl-plus", + "input": { + "messages": [ + { + "role": "user", + "content": [ + { + "text": "刚回家", + "image": + "https://dashscope.oss-cn-beijing.aliyuncs.com/images/dog_and_girl.jpeg" + } + ] + } + ] + }, + "parameters": {} +}; diff --git a/lib/apis/baidu_apis.dart b/lib/apis/baidu_apis.dart new file mode 100644 index 0000000..67083af --- /dev/null +++ b/lib/apis/baidu_apis.dart @@ -0,0 +1,99 @@ +// ignore_for_file: avoid_print + +import '../../dio_client/cus_http_client.dart'; +import '../../dio_client/cus_http_request.dart'; +import '../dio_client/interceptor_error.dart'; +import '../models/ai_interface_state/baidu_fuyu8b_state.dart'; +import '../services/cus_get_storage.dart'; +import '_self_keys.dart'; + +/// 百度平台大模型API的前缀地址 +const baiduAigcUrl = + "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/"; +// 百度的token请求地址 +const baiduAigcAuthUrl = "https://aip.baidubce.com/oauth/2.0/token"; + +// 百度平台下第三方的fuyu图像理解模型API接口 +const baiduFuyu8BUrl = + "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/image2text/fuyu_8b"; + +/// +/// 注意,这里没有处理报错,请求过程中的错误在cus_client中集中处理的; +/// 但请求结果处理的报错,就应该补上??? +/// + +/// +///----------------------------------------------------------------------------- +/// 百度的请求方法 +/// +/// 使用 AK,SK 生成鉴权签名(Access Token) +Future getAccessToken() async { + // 这个获取的token的结果是一个_Map,不用转json直接取就得到Access Token了 + + try { + var respData = await HttpUtils.post( + path: baiduAigcAuthUrl, + method: HttpMethod.post, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + data: { + "grant_type": "client_credentials", + // 如果是用户自行配置,使用自行配置的key;否则检查用户通用配置;最后才是自己的账号key + "client_id": MyGetStorage().getBaiduCommonAppId() ?? BAIDU_API_KEY, + "client_secret": + MyGetStorage().getBaiduCommonAppKey() ?? BAIDU_SECRET_KEY, + }, + ); + + // 响应是json格式 + return respData['access_token']; + } on HttpException catch (e) { + return e.msg; + } catch (e) { + print("bbbbbbbbbbbbbbbb ${e.runtimeType}---$e"); + // API请求报错,显示报错信息 + return e.toString(); + } +} + +/// 获取Fuyu-8B图像理解的响应结果 +Future getBaiduFuyu8BResp(String prompt, String image) async { + // 每次请求都要实时获取最小的token + String token = await getAccessToken(); + + var body = BaiduFuyu8BReq(prompt: prompt, image: image); + + try { + var start = DateTime.now().millisecondsSinceEpoch; + + var respData = await HttpUtils.post( + path: "$baiduFuyu8BUrl?access_token=$token", + method: HttpMethod.post, + headers: {"Content-Type": "application/json"}, + data: body, + ); + + var end = DateTime.now().millisecondsSinceEpoch; + + print("百度图生文-------耗时${(end - start) / 1000} 秒"); + print("百度图生文-------$respData"); + + // 响应是json格式 + return BaiduFuyu8BResp.fromJson(respData); + } on HttpException catch (e) { + return BaiduFuyu8BResp( + // 这里的code和msg就不是api返回的,是自行定义的,应该抽出来 + errorCode: e.code.toString(), + errorMsg: e.msg, + ); + } catch (e) { + print("vvvvvvvvvvvvvvvvvvl ${e.runtimeType}---$e"); + // API请求报错,显示报错信息 + return BaiduFuyu8BResp( + // 这里的code和msg就不是api返回的,是自行定义的,应该抽出来 + errorCode: "10000", + errorMsg: e.toString(), + ); + } +} diff --git a/lib/apis/common_chat_apis.dart b/lib/apis/common_chat_apis.dart new file mode 100644 index 0000000..a66357e --- /dev/null +++ b/lib/apis/common_chat_apis.dart @@ -0,0 +1,302 @@ +// ignore_for_file: avoid_print + +import 'dart:convert'; + +import '../dio_client/cus_http_client.dart'; +import '../dio_client/cus_http_request.dart'; +import '../dio_client/interceptor_error.dart'; +import '../models/ai_interface_state/platform_aigc_commom_state.dart'; +import '../models/common_llm_info.dart'; + +import '../services/cus_get_storage.dart'; +import '_self_keys.dart'; +import 'gen_access_token/tencet_hunyuan_signature_v3.dart'; + +/// 腾讯平台大模型API的前缀地址 +const tencentAigcUrl = "https://hunyuan.tencentcloudapi.com/"; + +/// +///----------------------------------------------------------------------------- +/// 腾讯的请求方法 +/// +/// 获取流式和非流式的对话响应数据 +Future> getTencentAigcResp( + List messages, { + String? model, + bool stream = false, + bool isUserConfig = true, +}) async { + print("-isUserConfig-----------------$isUserConfig"); + // 如果有传模型名称,就用传递的;没有就默认的 + model = model ?? newLLMSpecs[PlatformLLM.tencentHunyuanLiteFREE]!.model; + + var body = CommonReqBody(model: model, messages: messages, stream: stream); + + try { + var start = DateTime.now().millisecondsSinceEpoch; + var respData = await HttpUtils.post( + path: tencentAigcUrl, + method: HttpMethod.post, + headers: genHunyuanLiteSignatureHeaders( + commonReqBodyToJson(body, caseType: "pascal"), + // 如果是用户自行配置,使用用户通用配置;否则是自己的账号key + isUserConfig + ? MyGetStorage().getTencentCommonAppId() ?? "" + : TENCENT_SECRET_ID, + isUserConfig + ? MyGetStorage().getTencentCommonAppKey() ?? "" + : TENCENT_SECRET_KEY, + ), + // 可能是因为头的content type设定,这里直接传类实例即可,传toJson也可 + data: body.toJson(caseType: "pascal"), + ); + + var end = DateTime.now().millisecondsSinceEpoch; + print("腾讯aigc响应耗时: ${(end - start) / 1000} 秒"); + + /// ??? 流式返回都是String,没有区分正常和报错返回 + if (stream) { + List list = (respData as String) + .split("data:") + .where((e) => e.isNotEmpty) + .map((e) => CommonRespBody.fromJson(json.decode(e))) + .toList(); + return list; + } else { + /// 2024-06-06 注意,这里报错的时候,响应的是String,而正常获取回复响应是_Map + if (respData.runtimeType == String) { + respData = json.decode(respData); + } + + // 响应是json格式 + return [CommonRespBody.fromJson(respData["Response"])]; + } + } on HttpException catch (e) { + return [ + CommonRespBody( + customReplyText: e.toString(), + // 这里的code和msg就不是api返回的,是自行定义的,应该抽出来 + errorCode: e.code.toString(), + errorMsg: e.msg, + ) + ]; + } catch (e) { + print("ttttttttttttttttttttt ${e.runtimeType}---$e"); + // API请求报错,显示报错信息 + return [ + CommonRespBody( + customReplyText: e.toString(), + // 这里的code和msg就不是api返回的,是自行定义的,应该抽出来 + errorCode: "10000", + errorMsg: e.toString(), + ) + ]; + } +} + +/// 阿里平台通用aigc的请求地址 +var aliyunAigcUrl = + "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"; + +Future> getAliyunAigcResp( + List messages, { + String? model, + bool stream = false, + bool isUserConfig = true, +}) async { + // 如果有传模型名称,就用传递的;没有就默认的 + model = model ?? newLLMSpecs[PlatformLLM.aliyunQwen1p8BChatFREE]!.model; + + var body = CommonReqBody( + model: model, + input: AliyunInput(messages: messages), + parameters: stream + ? AliyunParameters(resultFormat: "message", incrementalOutput: true) + : AliyunParameters(resultFormat: "message"), + ); + + var start = DateTime.now().millisecondsSinceEpoch; + + var header = { + "Content-Type": "application/json", + // 如果是用户自行配置,使用检查用户通用配置;否则是自己的账号key + "Authorization": + "Bearer ${isUserConfig ? MyGetStorage().getAliyunCommonAppKey() : ALIYUN_API_KEY}", + }; + // 如果是流式,开启SSE + if (stream) { + header.addAll({"X-DashScope-SSE": "enable"}); + } + + try { + var respData = await HttpUtils.post( + path: aliyunAigcUrl, + method: HttpMethod.post, + headers: header, + // 可能是因为头的content type设定,这里直接传类实例即可,传toJson也可 + data: body, + ); + + var end = DateTime.now().millisecondsSinceEpoch; + print("阿里云aigc响应耗时: ${(end - start) / 1000} 秒"); + + if (stream) { + // 使用正则表达式匹配所有以"data:{"开头的字符串 + final regex = RegExp(r'.*data:\{".*}', multiLine: true); + final matches = regex.allMatches(respData); + + // 提取匹配到的字符串并添加到数组中 + List dataArray = []; + for (final match in matches) { + // 替换"data:"为空字符串(看结果data后面的冒号没有空格) + final replacedString = match.group(0)!.replaceAll(RegExp(r'data:'), ''); + dataArray.add(replacedString); + } + + List list = dataArray + .map((e) => CommonRespBody.fromJson(json.decode(e))) + .toList(); + + print(list); + + return list; + } else { + ///??? 2024-06-11 阿里云请求报错,会进入dio的错误拦截器,这里ret就是个null了 + if (respData.runtimeType == String) { + respData = json.decode(respData); + } + + // 响应是json格式 + return [CommonRespBody.fromJson(respData ?? {})]; + } + } on HttpException catch (e) { + return [ + CommonRespBody( + customReplyText: e.toString(), + // 这里的code和msg就不是api返回的,是自行定义的,应该抽出来 + errorCode: e.code.toString(), + errorMsg: e.msg, + ) + ]; + } catch (e) { + print("aaaaaaaaaaaaaaaaaaaa ${e.runtimeType}---$e"); + // API请求报错,显示报错信息 + return [ + CommonRespBody( + customReplyText: e.toString(), + // 这里的code和msg就不是api返回的,是自行定义的,应该抽出来 + errorCode: "10000", + errorMsg: e.toString(), + ) + ]; + } +} + +/// 百度平台大模型API的前缀地址 +const baiduAigcUrl = + "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/"; +// 百度的token请求地址 +const baiduAigcAuthUrl = "https://aip.baidubce.com/oauth/2.0/token"; + +/// +///----------------------------------------------------------------------------- +/// 百度的请求方法 +/// +/// 使用 AK,SK 生成鉴权签名(Access Token) +Future getAccessToken({ + bool isUserConfig = true, +}) async { + try { + // 这个获取的token的结果是一个_Map,不用转json直接取就得到Access Token了 + var respData = await HttpUtils.post( + path: baiduAigcAuthUrl, + method: HttpMethod.post, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + data: { + "grant_type": "client_credentials", + // 如果是用户自行配置,使用检查用户通用配置;否则是自己的账号key + "client_id": + isUserConfig ? MyGetStorage().getBaiduCommonAppId() : BAIDU_API_KEY, + "client_secret": isUserConfig + ? MyGetStorage().getBaiduCommonAppKey() + : BAIDU_SECRET_KEY, + }, + ); + + // 响应是json格式 + return respData['access_token']; + } on HttpException catch (e) { + return e.msg; + } catch (e) { + // API请求报错,显示报错信息 + return e.toString(); + } +} + +/// 获取流式响应数据 +Future> getBaiduAigcResp( + List messages, { + String? model, + bool stream = false, + bool isUserConfig = true, +}) async { + // 如果有传模型名称,就用传递的;没有就默认的 + // 百度免费的ernie-speed和ernie-lite 接口使用上是一致的,就是模型名称不一样 + model = model ?? newLLMSpecs[PlatformLLM.baiduErnieSpeed128KFREE]!.model; + + // 每次请求都要实时获取最小的token + String token = await getAccessToken(isUserConfig: isUserConfig); + + var body = CommonReqBody(messages: messages, stream: stream); + + var start = DateTime.now().millisecondsSinceEpoch; + + try { + var respData = await HttpUtils.post( + path: "$baiduAigcUrl$model?access_token=$token", + method: HttpMethod.post, + headers: {"Content-Type": "application/json"}, + data: body, + ); + + var end = DateTime.now().millisecondsSinceEpoch; + print("百度 aigc 响应耗时: ${(end - start) / 1000} 秒"); + + if (stream) { + List list = (respData as String) + .split("data:") + .where((e) => e.isNotEmpty) + .map((e) => CommonRespBody.fromJson(json.decode(e))) + .toList(); + + print("=======================bbbbbbbbbbbbb\n$list"); + + // 响应是json格式 + return list; + } else { + return [CommonRespBody.fromJson(respData)]; + } + } on HttpException catch (e) { + return [ + CommonRespBody( + customReplyText: e.toString(), + // 这里的code和msg就不是api返回的,是自行定义的,应该抽出来 + errorCode: e.code.toString(), + errorMsg: e.msg, + ) + ]; + } catch (e) { + print("bbbbbbbbbbbbbbbb ${e.runtimeType}---$e"); + // API请求报错,显示报错信息 + return [ + CommonRespBody( + customReplyText: e.toString(), + // 这里的code和msg就不是api返回的,是自行定义的,应该抽出来 + errorCode: "10000", + errorMsg: e.toString(), + ) + ]; + } +} diff --git a/lib/apis/gen_access_token/tencet_hunyuan_signature_v3.dart b/lib/apis/gen_access_token/tencet_hunyuan_signature_v3.dart new file mode 100644 index 0000000..6e15052 --- /dev/null +++ b/lib/apis/gen_access_token/tencet_hunyuan_signature_v3.dart @@ -0,0 +1,118 @@ +// ignore_for_file: avoid_print + +import 'dart:convert'; +import 'package:crypto/crypto.dart'; +import 'package:intl/intl.dart'; + +/// 这里是生成签名的方案 +/// 内容局限在使用hunyuan-lite大模型的时候。 +/// 参看 https://github.com/TencentCloud/signature-process-demo/blob/main/signature-v3/dart/lib/app.dart +/// 对照此处“签名示例”进行部分内容修改 +/// https://console.cloud.tencent.com/api/explorer?Product=hunyuan&Version=2023-09-01&Action=ChatCompletions +Map genHunyuanLiteSignatureHeaders( + String payloadString, + String id, + String key, +) { + // 密钥参数 + // 2024-06-16 支持用户自行输入自己的id和key + var secretId = id; + var secretKey = key; + + const service = 'hunyuan'; + const host = 'hunyuan.tencentcloudapi.com'; + const endpoint = 'https://$host'; + // const region = 'ap-guangzhou'; + const action = 'ChatCompletions'; + const version = '2023-09-01'; + const algorithm = 'TC3-HMAC-SHA256'; + // 获取当前时间戳 + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + // const timestamp = 1717057782; + + final date = DateFormat('yyyy-MM-dd').format( + DateTime.fromMillisecondsSinceEpoch(timestamp * 1000, isUtc: true), + ); + + // ************* 步骤 1:拼接规范请求串 ************* + const httpRequestMethod = 'POST'; + const canonicalUri = '/'; + const canonicalQuerystring = ''; + const contentType = 'application/json; charset=utf-8'; + var payload = payloadString; + + final canonicalHeaders = + 'content-type:$contentType\nhost:$host\nx-tc-action:${action.toLowerCase()}\n'; + const signedHeaders = 'content-type;host;x-tc-action'; + final hashedRequestPayload = sha256.convert(utf8.encode(payload)); + + final canonicalRequest = ''' +$httpRequestMethod +$canonicalUri +$canonicalQuerystring +$canonicalHeaders +$signedHeaders +$hashedRequestPayload'''; + + print("步骤 1:拼接规范请求串:=============="); + print(canonicalRequest); + + // ************* 步骤 2:拼接待签名字符串 ************* + final credentialScope = '$date/$service/tc3_request'; + final hashedCanonicalRequest = sha256.convert(utf8.encode(canonicalRequest)); + final stringToSign = ''' +$algorithm +$timestamp +$credentialScope +$hashedCanonicalRequest'''; + + print("步骤 2:拼接待签名字符串:============="); + print(stringToSign); + + // ************* 步骤 3:计算签名 ************* + List sign(List key, String msg) { + final hmacSha256 = Hmac(sha256, key); + return hmacSha256.convert(utf8.encode(msg)).bytes; + } + + final secretDate = sign(utf8.encode('TC3$secretKey'), date); + final secretService = sign(secretDate, service); + final secretSigning = sign(secretService, 'tc3_request'); + final signature = + Hmac(sha256, secretSigning).convert(utf8.encode(stringToSign)).toString(); + + print("步骤 3:计算签名:============="); + print(signature); + + // ************* 步骤 4:拼接 Authorization ************* + final authorization = + '$algorithm Credential=$secretId/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature'; + + print("步骤 4:拼接 Authorization:============="); + + print(authorization); + + print("在构建签名中的打印---------------------------"); + print( + 'curl -X POST $endpoint' + ' -H "Authorization: $authorization"' + ' -H "Content-Type: $contentType"' + ' -H "Host: $host"' + ' -H "X-TC-Action: $action"' + ' -H "X-TC-Timestamp: $timestamp"' + ' -H "X-TC-Version: $version"' + // ' -H "X-TC-Region: $region"' + ' -d \'$payload\'', + ); + + // 拼接header + var headers = { + "X-TC-Action": action, + "X-TC-Version": version, + "X-TC-Timestamp": timestamp, + "Content-Type": contentType, + "Authorization": authorization, + }; + + return headers; +} diff --git a/lib/apis/tencent_apis.dart b/lib/apis/tencent_apis.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/common/components/tool_widget.dart b/lib/common/components/tool_widget.dart new file mode 100644 index 0000000..59e9540 --- /dev/null +++ b/lib/common/components/tool_widget.dart @@ -0,0 +1,783 @@ +// ignore_for_file: avoid_print + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:carousel_slider/carousel_slider.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:dio/dio.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:form_builder_file_picker/form_builder_file_picker.dart'; +import 'package:image_gallery_saver/image_gallery_saver.dart'; +import 'package:multi_select_flutter/multi_select_flutter.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:photo_view/photo_view_gallery.dart'; + +import '../constants.dart'; + +// 绘制转圈圈 +Widget buildLoader(bool isLoading) { + if (isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } else { + return Container(); + } +} + +commonHintDialog(BuildContext context, String title, String message) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(title), + content: Text(message, style: TextStyle(fontSize: 12.sp)), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text("确定"), + ), + ], + ); + }, + ); +} + +// 显示底部提示条(默认都是出错或者提示的) +void showSnackMessage( + BuildContext context, + String message, { + Color? backgroundColor = Colors.red, +}) { + var snackBar = SnackBar( + content: Text(message), + duration: const Duration(seconds: 3), + backgroundColor: backgroundColor, + ); + + ScaffoldMessenger.of(context).showSnackBar(snackBar); +} + +/// 构建文本生成的图片结果列表 +/// 点击预览,长按下载 +buildNetworkImageViewGrid( + String style, + List urls, + BuildContext context, +) { + return GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + mainAxisSpacing: 5.sp, + crossAxisSpacing: 5.sp, + physics: const NeverScrollableScrollPhysics(), + children: buildImageList(style, urls, context), + ); +} + +// 2024-06-27 在小米6中此放在上面imageViewGrid没问题,但Z60U就报错;因为无法调试,错误原因不知 +// 所以在文生图历史记录中点击某个记录时,不使用上面那个,而使用这个 +buildImageList(String style, List urls, BuildContext context) { + return List.generate(urls.length, (index) { + return GridTile( + child: GestureDetector( + // 单击预览 + onTap: () { + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, // 设置背景透明 + child: PhotoView( + imageProvider: NetworkImage(urls[index]), + // 设置图片背景为透明 + backgroundDecoration: const BoxDecoration( + color: Colors.transparent, + ), + // 可以旋转 + // enableRotation: true, + // 缩放的最大最小限制 + minScale: PhotoViewComputedScale.contained * 0.8, + maxScale: PhotoViewComputedScale.covered * 2, + errorBuilder: (context, url, error) => + const Icon(Icons.error), + ), + ); + }, + ); + }, + // 长按保存到相册 + onLongPress: () async { + if (Platform.isAndroid) { + final deviceInfoPlugin = DeviceInfoPlugin(); + final deviceInfo = await deviceInfoPlugin.androidInfo; + final sdkInt = deviceInfo.version.sdkInt; + + // Android9对应sdk是28,<=28就不显示保存按钮 + if (sdkInt > 28) { + // 点击预览或者下载 + var response = await Dio().get(urls[index], + options: Options(responseType: ResponseType.bytes)); + + print(response.data); + + // 安卓9及以下好像无法保存 + final result = await ImageGallerySaver.saveImage( + Uint8List.fromList(response.data), + quality: 100, + name: "${style}_${DateTime.now().millisecondsSinceEpoch}", + ); + if (result["isSuccess"] == true) { + EasyLoading.showToast("图片已保存到相册!"); + } else { + EasyLoading.showToast("无法保存图片!"); + } + } else { + EasyLoading.showToast("Android 9 及以下版本无法长按保存到相册!"); + } + } + }, + // 默认缓存展示 + child: SizedBox( + height: 0.2.sw, + child: CachedNetworkImage( + imageUrl: urls[index], + fit: BoxFit.cover, + progressIndicatorBuilder: (context, url, downloadProgress) => + Center( + child: SizedBox( + height: 50.sp, + width: 50.sp, + child: CircularProgressIndicator( + value: downloadProgress.progress, + ), + ), + ), + errorWidget: (context, url, error) => const Icon(Icons.error), + ), + ), + ), + ); + }).toList(); +} + +/// 构建图片预览,可点击放大 +/// 注意限定传入的图片类型,要在这些条件之中 +Widget buildImageView( + dynamic image, + BuildContext context, { + // 是否是本地文件地址(暂时没使用到网络地址) + bool? isFileUrl = false, +}) { + // 如果没有图片数据,直接返回文提示 + if (image == null) { + return const Center( + child: Text( + '请选择图片', + style: TextStyle(color: Colors.grey), + ), + ); + } + + print("显示的图片类型---${image.runtimeType == File}-${image.runtimeType} -$image"); + + ImageProvider imageProvider; + // 只有base64的字符串或者文件格式 + if (image.runtimeType == String && isFileUrl == false) { + imageProvider = MemoryImage(base64Decode(image)); + } + if (image.runtimeType == String && isFileUrl == true) { + imageProvider = FileImage(File(image)); + } else { + // 如果直接传文件,那就是文件 + imageProvider = FileImage(image); + } + + return GridTile( + child: GestureDetector( + // 单击预览 + onTap: () { + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, // 设置背景透明 + child: PhotoView( + imageProvider: imageProvider, + // 设置图片背景为透明 + backgroundDecoration: const BoxDecoration( + color: Colors.transparent, + ), + // 可以旋转 + // enableRotation: true, + // 缩放的最大最小限制 + minScale: PhotoViewComputedScale.contained * 0.8, + maxScale: PhotoViewComputedScale.covered * 2, + errorBuilder: (context, url, error) => const Icon(Icons.error), + ), + ); + }, + ); + }, + // 默认显示文件图片 + child: RepaintBoundary( + child: Center( + child: Image(image: imageProvider, fit: BoxFit.scaleDown), + ), + ), + ), + ); +} + +// 生成随机颜色 +Color genRandomColor() => + Color((math.Random().nextDouble() * 0xFFFFFF).toInt()).withOpacity(1.0); + +// 生成随机颜色带透明度 +Color genRandomColorWithOpacity({double? opacity}) => + Color((math.Random().nextDouble() * 0xFFFFFF).toInt()) + .withOpacity(opacity ?? math.Random().nextDouble()); + +// 指定长度的随机字符串 +const _chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890'; +math.Random _rnd = math.Random(); +String getRandomString(int length) { + return String.fromCharCodes( + Iterable.generate( + length, + (_) => _chars.codeUnitAt(_rnd.nextInt(_chars.length)), + ), + ); +} + +// 指定长度的范围的随机字符串(包含上面那个,最大最小同一个值即可) +String generateRandomString(int minLength, int maxLength) { + int length = minLength + _rnd.nextInt(maxLength - minLength + 1); + + return String.fromCharCodes( + Iterable.generate( + length, + (_) => _chars.codeUnitAt(_rnd.nextInt(_chars.length)), + ), + ); +} + +// 异常弹窗 +commonExceptionDialog(BuildContext context, String title, String message) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(title), + content: Text(message, style: const TextStyle(fontSize: 13)), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text("确定"), + ), + ], + ); + }, + ); +} + +/// +/// form builder 库中文本栏位和下拉选择框组件的二次封装 +/// +// 构建表单的文本输入框 +Widget cusFormBuilerTextField(String name, + {String? initialValue, + double? valueFontSize, + int? maxLines, + String? hintText, // 可不传提示语 + TextStyle? hintStyle, + String? labelText, // 可不传栏位标签,在输入框前面有就行 + String? Function(Object?)? validator, + bool? isOutline = false, // 输入框是否有线条 + bool isReadOnly = false, // 输入框是否有线条 + TextInputType? keyboardType, + void Function(String?)? onChanged, + List? inputFormatters}) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 10.sp), + child: FormBuilderTextField( + name: name, + initialValue: initialValue, + maxLines: maxLines, + readOnly: isReadOnly, + style: TextStyle(fontSize: valueFontSize), + // 2023-12-04 没有传默认使用name,原本默认的.text会弹安全键盘,可能无法输入中文 + // 2023-12-21 enableSuggestions 设为 true后键盘类型为text就正常了。 + // 注意:如果有最大行超过1的话,默认启用多行的键盘类型 + enableSuggestions: true, + keyboardType: keyboardType ?? + ((maxLines != null && maxLines > 1) + ? TextInputType.multiline + : TextInputType.text), + + decoration: _buildInputDecoration( + isOutline, + isReadOnly, + labelText, + hintText, + hintStyle, + ), + validator: validator, + onChanged: onChanged, + // 输入的格式限制 + inputFormatters: inputFormatters, + ), + ); +} + +/// 构建下拉多选弹窗模块栏位(主要为了样式统一) +Widget buildModifyMultiSelectDialogField( + BuildContext context, { + required List items, + GlobalKey>? key, + List initialValue = const [], + String? labelText, + String? hintText, + String? Function(List?)? validator, + required void Function(List) onConfirm, +}) { + // 把预设的基础活动选项列表转化为 MultiSelectDialogField 支持的列表 + final formattedItems = items + .map>( + (opt) => MultiSelectItem(opt, opt.cnLabel)) + .toList(); + + return Padding( + padding: EdgeInsets.symmetric(horizontal: 10.sp), + child: MultiSelectDialogField( + key: key, + items: formattedItems, + // ???? 好像是不带validator用了这个初始值就会报错 + initialValue: initialValue, + title: Text(hintText ?? ''), + // selectedColor: Colors.blue, + decoration: BoxDecoration( + // color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.all(Radius.circular(5.sp)), + border: Border.all( + width: 2.sp, + color: Theme.of(context).disabledColor, + ), + ), + // buttonIcon: const Icon(Icons.fitness_center, color: Colors.blue), + buttonIcon: const Icon(Icons.restaurant_menu), + buttonText: Text( + labelText ?? "", + style: TextStyle( + // color: Colors.blue[800], + fontSize: 12.sp, + ), + ), + // searchable: true, + validator: validator, + onConfirm: onConfirm, + cancelText: const Text("取消"), + confirmText: const Text("确认"), + ), + ); +} + +// formbuilder 下拉框和文本输入框的样式等内容 +InputDecoration _buildInputDecoration( + bool? isOutline, + bool isReadOnly, + String? labelText, + String? hintText, + TextStyle? hintStyle, +) { + final contentPadding = isOutline != null && isOutline + ? EdgeInsets.symmetric(horizontal: 5.sp, vertical: 15.sp) + : EdgeInsets.symmetric(horizontal: 5.sp, vertical: 5.sp); + + return InputDecoration( + isDense: true, + labelText: labelText, + hintText: hintText, + hintStyle: hintStyle, + contentPadding: contentPadding, + border: isOutline != null && isOutline + ? OutlineInputBorder( + borderRadius: BorderRadius.circular(10.0), + ) + : isReadOnly + ? InputBorder.none + : null, + // 设置透明底色 + filled: true, + fillColor: Colors.transparent, + ); +} + +buildSmallChip( + String labelText, { + Color? bgColor, + double? labelTextSize, +}) { + return Chip( + label: Text(labelText), + backgroundColor: bgColor, + labelStyle: TextStyle(fontSize: labelTextSize), + labelPadding: EdgeInsets.zero, + // 设置负数会报错,但好像看到有点效果呢 + // labelPadding: EdgeInsets.fromLTRB(0, -6.sp, 0, -6.sp), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); +} + +// 用一个按钮假装是一个标签,用来展示 +buildSmallButtonTag( + String labelText, { + Color? bgColor, + double? labelTextSize, +}) { + return RawMaterialButton( + onPressed: () {}, + constraints: const BoxConstraints(), + padding: const EdgeInsets.fromLTRB(8.0, 4.0, 8.0, 4.0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), + ), + fillColor: bgColor ?? Colors.grey[300], + child: Text( + labelText, + style: TextStyle(fontSize: labelTextSize ?? 12.sp), + ), + ); +} + +// 一般当做标签用,比上面个还小 +// 传入的字体最好不超过10 +buildTinyButtonTag( + String labelText, { + Color? bgColor, + double? labelTextSize, +}) { + return SizedBox( + // 传入大于12的字体,修正为12;不传则默认12 + height: ((labelTextSize != null && labelTextSize > 10.sp) + ? 10.sp + : labelTextSize ?? 10.sp) + + 10.sp, + child: RawMaterialButton( + onPressed: () {}, + constraints: const BoxConstraints(), + padding: EdgeInsets.fromLTRB(4.sp, 2.sp, 4.sp, 2.sp), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.sp), + ), + fillColor: bgColor ?? Colors.grey[300], + child: Text( + labelText, + style: TextStyle( + // 传入大于10的字体,修正为10;不传则默认10 + fontSize: (labelTextSize != null && labelTextSize > 10.sp) + ? 10.sp + : labelTextSize ?? 10.sp, + ), + ), + ), + ); +} + +// 带有横线滚动条的datatable +buildDataTableWithHorizontalScrollbar({ + required ScrollController scrollController, + required List columns, + required List rows, +}) { + return Scrollbar( + thickness: 5, + // 设置交互模式后,滚动条和手势滚动方向才一致 + interactive: true, + radius: Radius.circular(5.sp), + // 不设置这个,滚动条默认不显示,在滚动时才显示 + thumbVisibility: true, + // trackVisibility: true, + // 滚动条默认在右边,要改在左边就配合Transform进行修改(此例没必要) + // 刻意预留一点空间给滚动条 + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + scrollDirection: Axis.horizontal, + child: DataTable( + // dataRowHeight: 10.sp, + dataRowMinHeight: 60.sp, // 设置行高范围 + dataRowMaxHeight: 100.sp, + headingRowHeight: 25, // 设置表头行高 + horizontalMargin: 10, // 设置水平边距 + columnSpacing: 20.sp, // 设置列间距 + columns: columns, + rows: rows, + ), + ), + ); +} + +/// ---- +/// +// 图片轮播 +buildImageCarouselSlider( + List imageList, { + bool isNoImage = false, // 是否不显示图片,默认就算无图片也显示占位图片 + int type = 3, // 轮播图是否可以点击预览图片,预设为3(具体类型参看下方实现方法) +}) { + return CarouselSlider( + options: CarouselOptions( + autoPlay: true, // 自动播放 + enlargeCenterPage: true, // 居中图片放大 + aspectRatio: 16 / 9, // 图片宽高比 + viewportFraction: 1, // 图片占屏幕宽度的比例 + // 只有一张图片时不滚动 + enableInfiniteScroll: imageList.length > 1, + ), + // 除非指定不显示图片,否则没有图片也显示一张占位图片 + items: isNoImage + ? null + : imageList.isEmpty + ? [Image.asset(placeholderImageUrl, fit: BoxFit.scaleDown)] + : imageList.map((imageUrl) { + return Builder( + builder: (BuildContext context) { + return _buildImageCarouselSliderType( + type, + context, + imageUrl, + imageList, + ); + }, + ); + }).toList(), + ); +} + +// 2024-03-12 根据图片地址前缀来区分是否是网络图片,使用不同的方式展示图片 +Widget buildNetworkOrFileImage(String imageUrl, {BoxFit? fit}) { + if (imageUrl.startsWith('http') || imageUrl.startsWith('https')) { + return CachedNetworkImage( + imageUrl: imageUrl, + fit: fit, + // progressIndicatorBuilder: (context, url, progress) => Center( + // child: CircularProgressIndicator( + // value: progress.progress, + // ), + // ), + + /// placeholder 和 progressIndicatorBuilder 只能2选1 + // placeholder: (context, url) => Center( + // child: SizedBox( + // width: 50.sp, + // height: 50.sp, + // child: const CircularProgressIndicator(), + // ), + // ), + errorWidget: (context, url, error) => Center( + child: Icon(Icons.error, size: 36.sp), + ), + ); + +// 2024-03-29 这样每次都会重新请求图片,网络图片都不小的,流量顶不住。用上面的 + // return Image.network( + // imageUrl, + // errorBuilder: (context, error, stackTrace) { + // return Image.asset(placeholderImageUrl, fit: BoxFit.scaleDown); + // }, + // fit: fit, + // ); + } else { + return Image.file( + File(imageUrl), + errorBuilder: (context, error, stackTrace) { + return Image.asset(placeholderImageUrl, fit: BoxFit.scaleDown); + }, + fit: fit, + ); + } +} + +// 2024-03-12 根据图片地址前缀来区分是否是网络图片 +bool isNetworkImageUrl(String imageUrl) { + return (imageUrl.startsWith('http') || imageUrl.startsWith('https')); +} + +ImageProvider getImageProvider(String imageUrl) { + if (imageUrl.startsWith('http') || imageUrl.startsWith('https')) { + return CachedNetworkImageProvider(imageUrl); + // return NetworkImage(imageUrl); + } else { + return FileImage(File(imageUrl)); + } +} + +/// 2023-12-26 +/// 现在设计轮播图3种形态: +/// 1 点击某张图片,可以弹窗显示该图片并进行缩放预览 +/// 2 点击某张图片,可以跳转新页面对该图片并进行缩放预览 +/// 3 点击某张图片,可以弹窗对该图片所在整个列表进行缩放预览(默认选项) +/// default 单纯的轮播展示,点击图片无动作 +_buildImageCarouselSliderType( + int type, + BuildContext context, + String imageUrl, + List imageList, +) { + buildCommonImageWidget(Function() onTap) => + GestureDetector(onTap: onTap, child: buildNetworkOrFileImage(imageUrl)); + + switch (type) { + // 这个直接弹窗显示图片可以缩放 + case 1: + return buildCommonImageWidget(() { + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, // 设置背景透明 + child: PhotoView( + imageProvider: getImageProvider(imageUrl), + // 设置图片背景为透明 + backgroundDecoration: const BoxDecoration( + color: Colors.transparent, + ), + // 可以旋转 + enableRotation: true, + // 缩放的最大最小限制 + minScale: PhotoViewComputedScale.contained * 0.8, + maxScale: PhotoViewComputedScale.covered * 2, + errorBuilder: (context, url, error) => const Icon(Icons.error), + ), + ); + }, + ); + }); + case 2: + return buildCommonImageWidget(() { + // 这个是跳转到新的页面去 + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PhotoView( + imageProvider: getImageProvider(imageUrl), + enableRotation: true, + errorBuilder: (context, url, error) => const Icon(Icons.error), + ), + ), + ); + }); + case 3: + return buildCommonImageWidget(() { + showDialog( + context: context, + builder: (BuildContext context) { + // 这个弹窗默认是无法全屏的,上下左右会留点空,点击这些空隙可以关闭弹窗 + return Dialog( + backgroundColor: Colors.transparent, + child: PhotoViewGallery.builder( + itemCount: imageList.length, + builder: (BuildContext context, int index) { + return PhotoViewGalleryPageOptions( + imageProvider: getImageProvider(imageList[index]), + errorBuilder: (context, url, error) => + const Icon(Icons.error), + ); + }, + // enableRotation: true, + scrollPhysics: const BouncingScrollPhysics(), + backgroundDecoration: const BoxDecoration( + color: Colors.transparent, + ), + loadingBuilder: (BuildContext context, ImageChunkEvent? event) { + return const Center(child: CircularProgressIndicator()); + }, + ), + ); + }, + ); + }); + default: + return Container( + width: MediaQuery.of(context).size.width, + margin: const EdgeInsets.symmetric(horizontal: 5.0), + decoration: const BoxDecoration(color: Colors.grey), + child: buildNetworkOrFileImage(imageUrl), + ); + } +} + +// 将图片字符串,转为文件多选框中支持的图片平台文件 +// formbuilder的图片地址拼接的字符串,要转回平台文件列表 +List convertStringToPlatformFiles(String imagesString) { + List imageUrls = imagesString.split(','); // 拆分字符串 + // 如果本身就是空字符串,直接返回空平台文件数组 + if (imagesString.trim().isEmpty || imageUrls.isEmpty) { + return []; + } + + List platformFiles = []; // 存储 PlatformFile 对象的列表 + + for (var imageUrl in imageUrls) { + PlatformFile file = PlatformFile( + name: imageUrl, + path: imageUrl, + size: 32, // 假设图片地址即为文件路径 + ); + platformFiles.add(file); + } + + return platformFiles; +} + +/// 显示本地路径图片,点击可弹窗显示并缩放 +buildClickImageDialog(BuildContext context, String imageUrl) { + return GestureDetector( + onTap: () { + // 在当前上下文中查找最近的 FocusScope 并使其失去焦点,从而收起键盘。 + FocusScope.of(context).unfocus(); + // 这个直接弹窗显示图片可以缩放 + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, // 设置背景透明 + child: PhotoView( + imageProvider: FileImage(File(imageUrl)), + // 设置图片背景为透明 + backgroundDecoration: const BoxDecoration( + color: Colors.transparent, + ), + // 可以旋转 + // enableRotation: true, + // 缩放的最大最小限制 + minScale: PhotoViewComputedScale.contained * 0.8, + maxScale: PhotoViewComputedScale.covered * 2, + errorBuilder: (context, url, error) => const Icon(Icons.error), + ), + ); + }, + ); + }, + child: Padding( + padding: EdgeInsets.all(20.sp), + child: SizedBox( + width: 0.8.sw, + child: buildNetworkOrFileImage(imageUrl), + ), + ), + ); +} diff --git a/lib/common/constants.dart b/lib/common/constants.dart new file mode 100644 index 0000000..d4962e1 --- /dev/null +++ b/lib/common/constants.dart @@ -0,0 +1,105 @@ +// 时间格式化字符串 +const constDatetimeFormat = "yyyy-MM-dd HH:mm:ss"; +const constDateFormat = "yyyy-MM-dd"; +const constMonthFormat = "yyyy-MM"; +const constTimeFormat = "HH:mm:ss"; +// 未知的时间字符串 +const unknownDateTimeString = '1970-01-01 00:00:00'; +const unknownDateString = '1970-01-01'; + +const String placeholderImageUrl = 'assets/images/no_image.jpg'; + +// 数据库分页查询数据的时候,还需要带上一个该表的总数量 +// 还可以按需补入其他属性 +class CusDataResult { + List data; + int total; + + CusDataResult({ + required this.data, + required this.total, + }); +} + +// 自定义标签,常用来存英文、中文、全小写带下划线的英文等。 +class CusLabel { + final String enLabel; + final String cnLabel; + final dynamic value; + + CusLabel({ + required this.enLabel, + required this.cnLabel, + required this.value, + }); +} + +// 菜品的分类和标签都用预设的 +// 2024-03-10 这个项目取值都直接取value,就不区别中英文了 +List dishTagOptions = [ + CusLabel(enLabel: 'LuCuisine', cnLabel: "鲁菜", value: '鲁菜'), + CusLabel(enLabel: 'ChuanCuisine', cnLabel: "川菜", value: '川菜'), + CusLabel(enLabel: 'YueCuisine', cnLabel: "粤菜", value: '粤菜'), + CusLabel(enLabel: 'SuCuisine', cnLabel: "苏菜", value: '苏菜'), + CusLabel(enLabel: 'MinCuisine', cnLabel: "闽菜", value: '闽菜'), + CusLabel(enLabel: 'ZheCuisine', cnLabel: "浙菜", value: '浙菜'), + CusLabel(enLabel: 'XiangCuisine', cnLabel: "湘菜", value: '湘菜'), + CusLabel(enLabel: 'stir-fried', cnLabel: "炒", value: '炒'), + CusLabel(enLabel: 'Quick-fry', cnLabel: "爆", value: '爆'), + CusLabel(enLabel: 'sauté', cnLabel: "熘", value: '熘'), + CusLabel(enLabel: 'fry', cnLabel: "炸", value: '炸'), + CusLabel(enLabel: 'boil', cnLabel: "烹", value: '烹'), + CusLabel(enLabel: 'decoct', cnLabel: "煎", value: '煎'), + CusLabel(enLabel: 'paste', cnLabel: "贴", value: '贴'), + CusLabel(enLabel: 'bake', cnLabel: "烧", value: '烧'), + CusLabel(enLabel: 'sweat', cnLabel: "焖", value: '焖'), + CusLabel(enLabel: 'stew', cnLabel: "炖", value: '炖'), + CusLabel(enLabel: 'steam', cnLabel: "蒸", value: '蒸'), + CusLabel(enLabel: 'quick-boil', cnLabel: "汆", value: '汆'), + CusLabel(enLabel: 'boil', cnLabel: "煮", value: '煮'), + CusLabel(enLabel: 'braise', cnLabel: "烩", value: '烩'), + CusLabel(enLabel: 'Qiang', cnLabel: "炝", value: '炝'), + CusLabel(enLabel: 'salt', cnLabel: "腌", value: '腌'), + CusLabel(enLabel: 'stir-and-mix', cnLabel: "拌", value: '拌'), + CusLabel(enLabel: 'roast', cnLabel: "烤", value: '烤'), + CusLabel(enLabel: 'bittern', cnLabel: "卤", value: '卤'), + CusLabel(enLabel: 'freeze', cnLabel: "冻", value: '冻'), + CusLabel(enLabel: 'wire-drawing', cnLabel: "拔丝", value: '拔丝'), + CusLabel(enLabel: 'honey-sauce', cnLabel: "蜜汁", value: '蜜汁'), + CusLabel(enLabel: 'smoked', cnLabel: "熏", value: '熏'), + CusLabel(enLabel: 'roll', cnLabel: "卷", value: '卷'), + CusLabel(enLabel: 'other', cnLabel: "其他技法", value: '其他技法'), +]; + +List dishCateOptions = [ + CusLabel(enLabel: 'Breakfast', cnLabel: "早餐", value: '早餐'), + CusLabel(enLabel: 'Lunch', cnLabel: "早茶", value: '早茶'), + CusLabel(enLabel: 'Lunch', cnLabel: "午餐", value: '午餐'), + CusLabel(enLabel: 'AfternoonTea', cnLabel: "下午茶", value: '下午茶'), + CusLabel(enLabel: 'Dinner', cnLabel: "晚餐", value: '晚餐'), + CusLabel(enLabel: 'MidnightSnack', cnLabel: "夜宵", value: '夜宵'), + CusLabel(enLabel: 'Dessert', cnLabel: "甜点", value: '甜点'), + CusLabel(enLabel: 'StapleFood', cnLabel: "主食", value: '主食'), + CusLabel(enLabel: 'Other', cnLabel: "其他", value: '其他'), +]; + +// 进入对话页面简单预设的一些问题 +List defaultChatQuestions = [ + "你好,介绍一下你自己。", + "将“纵观世界风云,风景这边独好”这句话,翻译成英语、日语、俄语和西班牙语。", + "介绍一下“受害者有罪论”,并分析这个说法是否合理。", + "老板经常以未达到工作考核来克扣工资,经常让我无偿加班,是否已经违法?", + "你是一位产品文案。请设计一份PPT大纲,介绍你们公司新推出的防晒霜,要求言简意赅并且具有创意。", + "你是一位10w+爆款文章的编辑。请结合赛博玄学主题,如电子木鱼、机甲佛祖、星座、塔罗牌、人形锦鲤、工位装修等,用俏皮有网感的语言撰写一篇公众号文章。", + "我是小区物业人员,小区下周六(9.30号)下午16:00-18:00,因为电力改造施工要停电,请帮我拟一份停电通知。", + "一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法。", + "使用python3编写一个快速排序算法。", + // "你是一个营养师。现在请帮我制定一周的健康减肥食谱。", + // "小明因为女朋友需要的高额彩礼费而伤心焦虑,请帮我安慰一下他。", + // "请为一家互联网公司写一则差旅费用管理规则。", + // "小王最近天天加班,压力很大,心情很糟。也想着跳槽,但是就业大环境很差,不容易找到新工作。现在他很迷茫,请帮他出出主意。", + // "使用python3编写一个快速排序算法。", + // "如果我的邻居持续发出噪音严重影响我的生活,除了民法典1032条,还有什么法条支持居民向噪音发出者维权?", + // "请帮我写一份通用的加薪申请模板。", + // "一个长方体的棱长和是144厘米,它的长、宽、高之比是4:3:2,长方体的体积是多少?", +]; diff --git a/lib/common/db_tools/db_helper.dart b/lib/common/db_tools/db_helper.dart new file mode 100644 index 0000000..231d4cd --- /dev/null +++ b/lib/common/db_tools/db_helper.dart @@ -0,0 +1,769 @@ +// ignore_for_file: avoid_print, constant_identifier_names + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:sqflite/sqflite.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../models/brief_accounting_state.dart'; +import '../../models/dish.dart'; +import '../../models/llm_chat_state.dart'; +import '../../models/llm_text2image_state.dart'; +import '../constants.dart'; +import 'ddl_ai_light_life.dart'; + +// 导出表文件临时存放的文件夹 +const DB_EXPORT_DIR = "db_export"; +// 导出的表前缀 +const DB_EXPORT_TABLE_PREFIX = "all_"; + +class DBHelper { + /// + /// 数据库初始化相关 + /// + + // 单例模式 + static final DBHelper _dbHelper = DBHelper._createInstance(); + // 构造函数,返回单例 + factory DBHelper() => _dbHelper; + // 数据库实例 + static Database? _database; + + // 创建sqlite的db文件成功后,记录该地址,以便删除时使用。 + var dbFilePath = ""; + + // 命名的构造函数用于创建DatabaseHelper的实例 + DBHelper._createInstance(); + + // 获取数据库实例 + Future get database async => _database ??= await initializeDB(); + + // 初始化数据库 + Future initializeDB() async { + // 获取Android和iOS存储数据库的目录路径(用户看不到,在Android/data/……里看不到)。 + // Directory directory = await getApplicationDocumentsDirectory(); + + // IOS不支持这个方法,所以可能取不到这个地址 + Directory? directory2 = await getExternalStorageDirectory(); + String path = "${directory2?.path}/${AILightLifeDdl.databaseName}"; + + print("初始化 DB sqlite数据库存放的地址:$path"); + + // 在给定路径上打开/创建数据库 + var dietaryDb = await openDatabase(path, version: 1, onCreate: _createDb); + dbFilePath = path; + return dietaryDb; + } + + // 创建训练数据库相关表 + void _createDb(Database db, int newVersion) async { + print("开始创建表 _createDb……"); + + await db.transaction((txn) async { + // txn.execute(BriefAccountingDdl.ddlForExpend); + // txn.execute(BriefAccountingDdl.ddlForIncome); + txn.execute(AILightLifeDdl.ddlForBillItem); + txn.execute(AILightLifeDdl.ddlForChatHistory); + txn.execute(AILightLifeDdl.ddlForText2ImageHistory); + txn.execute(AILightLifeDdl.ddlForDish); + }); + } + + // 关闭数据库 + Future closeDB() async { + Database db = await database; + + print("db.isOpen ${db.isOpen}"); + await db.close(); + print("db.isOpen ${db.isOpen}"); + + // 删除db或者关闭db都需要重置db为null, + // 否则后续会保留之前的连接,以致出现类似错误:Unhandled Exception: DatabaseException(database_closed 5) + // https://github.com/tekartik/sqflite/issues/223 + _database = null; + + // 如果已经关闭了,返回ture + return !db.isOpen; + } + + // 删除sqlite的db文件(初始化数据库操作中那个path的值) + Future deleteDB() async { + print("开始删除內嵌的 sqlite db文件,db文件地址:$dbFilePath"); + + // 先删除,再重置,避免仍然存在其他线程在访问数据库,从而导致删除失败 + await deleteDatabase(dbFilePath); + + // 删除db或者关闭db都需要重置db为null, + // 否则后续会保留之前的连接,以致出现类似错误:Unhandled Exception: DatabaseException(database_closed 5) + // https://stackoverflow.com/questions/60848752/delete-database-when-log-out-and-create-again-after-log-in-dart + _database = null; + } + + // 显示db中已有的table,默认的和自建立的 + void showTableNameList() async { + Database db = await database; + var tableNames = (await db.query( + 'sqlite_master', + where: 'type = ?', + whereArgs: ['table'], + )) + .map((row) => row['name'] as String) + .toList(growable: false); + + print("DB中拥有的表名:------------"); + print(tableNames); + } + + // 导出所有数据 + Future exportDatabase() async { + // 获取应用文档目录路径 + Directory appDocDir = await getApplicationDocumentsDirectory(); + // 创建或检索 db_export 文件夹 + var tempDir = await Directory( + p.join(appDocDir.path, DB_EXPORT_DIR), + ).create(); + + // 打开数据库 + Database db = await database; + + // 获取所有表名 + List> tables = + await db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'"); + + // 遍历所有表 + for (Map table in tables) { + String tableName = table['name']; + // 不是自建的表,不导出 + if (!tableName.startsWith(DB_EXPORT_TABLE_PREFIX)) { + continue; + } + + String tempFilePath = p.join(tempDir.path, '$tableName.json'); + + // 查询表中所有数据 + List> result = await db.query(tableName); + + // 将结果转换为JSON字符串 + String jsonStr = jsonEncode(result); + + // 创建临时导出文件 + File tempFile = File(tempFilePath); + + // 将JSON字符串写入临时文件 + await tempFile.writeAsString(jsonStr); + + // print('表 $tableName 已成功导出到:$tempFilePath'); + } + } + + /// + /// Helper 的相关方法 + /// + + ///***********************************************/ + /// BillItem 的相关操作 + /// + + // 新增(只有单个的时候就一个值得数组) + Future> insertBillItemList(List billItems) async { + var batch = (await database).batch(); + for (var item in billItems) { + batch.insert(AILightLifeDdl.tableNameOfBillItem, item.toMap()); + } + + print("新增账单条目了$billItems"); + return await batch.commit(); + } + + // 修改单条 + Future updateBillItem(BillItem item) async => (await database).update( + AILightLifeDdl.tableNameOfBillItem, + item.toMap(), + where: 'bill_item_id = ?', + whereArgs: [item.billItemId], + ); + + // 删除单条 + Future deleteBillItemById(String billItemId) async => + (await database).delete( + AILightLifeDdl.tableNameOfBillItem, + where: "bill_item_id=?", + whereArgs: [billItemId], + ); + + // 账单查询默认查询所有不分页(全部查询到但加载时上滑显示更多;还是上滑时再查询???) + // 但前端不会显示查询所有的选项,而是会指定日期范围 + // 一般是当日、当月、当年、最近3年,更多自定义范围根据需要来看是否支持 + Future queryBillItemList({ + String? billItemId, + int? itemType, // 0 收入,1 支出 + String? itemKeyword, // 条目关键字 + String? startDate, // 日期范围 + String? endDate, + double? minValue, // 金额范围 + double? maxValue, + int? page, + int? pageSize, // 不传就默认为10 + }) async { + Database db = await database; + + print("账单查询传入的条件:"); + print("billItemId $billItemId"); + print("itemType $itemType"); + print("itemKeyword $itemKeyword"); + print("startDate $startDate"); + print("endDate $endDate"); + print("page $page"); + print("pageSize $pageSize"); + + // 分页相关处理 + page ??= 1; + // 如果size为0,则查询所有(暂时这个所有就10w吧) + if (pageSize == 0) { + pageSize = 100000; + } else if (pageSize == null || pageSize < 1 && pageSize != 0) { + pageSize = 10; + } + + print("page2222 $page"); + print("pageSize2222 $pageSize"); + + final offset = (page - 1) * pageSize; + + final where = []; + final whereArgs = []; + + if (billItemId != null) { + where.add('bill_item_id = ?'); + whereArgs.add(billItemId); + } + + if (itemType != null) { + where.add('item_type = ?'); + whereArgs.add(itemType); + } + + if (itemKeyword != null) { + where.add('item LIKE ?'); + whereArgs.add("%$itemKeyword%"); + } + + if (startDate != null && startDate != "") { + where.add(" date >= ? "); + whereArgs.add(startDate); + } + if (endDate != null && endDate != "") { + where.add(" date <= ? "); + whereArgs.add(endDate); + } + + if (minValue != null) { + where.add(" value >= ? "); + whereArgs.add(minValue); + } + if (maxValue != null) { + where.add(" value <= ? "); + whereArgs.add(maxValue); + } + + final rows = await db.query( + AILightLifeDdl.tableNameOfBillItem, + where: where.isNotEmpty ? where.join(' AND ') : null, + whereArgs: whereArgs.isNotEmpty ? whereArgs : null, + limit: pageSize, + offset: offset, + orderBy: "date DESC", + ); + + // 数据是分页查询的,但这里带上满足条件的一共多少条 + String sql = + 'SELECT COUNT(*) FROM ${AILightLifeDdl.tableNameOfBillItem}'; + if (where.isNotEmpty) { + sql += ' WHERE ${where.join(' AND ')}'; + } + + int totalCount = + Sqflite.firstIntValue(await db.rawQuery(sql, whereArgs)) ?? 0; + + print(sql); + print("whereArgs $whereArgs totalCount $totalCount"); + + var dishes = rows.map((row) => BillItem.fromMap(row)).toList(); + + return CusDataResult(data: dishes, total: totalCount); + } + + Future queryBillItemWithBillCountList({ + String? startDate, // 日期范围 + String? endDate, + int? page, + int? pageSize, // 不传就默认为10 + }) async { + Database db = await database; + + print("queryBillItemWithBillCountList 账单查询传入的条件:"); + print("startDate $startDate"); + print("endDate $endDate"); + print("page $page"); + print("pageSize $pageSize"); + + // 分页相关处理 + page ??= 1; + // 如果size为0,则查询所有(暂时这个所有就10w吧) + if (pageSize == 0) { + pageSize = 100000; + } else if (pageSize == null || pageSize < 1 && pageSize != 0) { + pageSize = 10; + } + + print("page2222 $page"); + print("pageSize2222 $pageSize"); + + final offset = (page - 1) * pageSize; + + final where = []; + final whereArgs = []; + + var formatStr = "%Y-%m"; + var rangeWhere = ""; + if (startDate != null && endDate != null) { + rangeWhere = 'WHERE "date" BETWEEN "$startDate" AND "$endDate"'; + } + + // var sql2 = """ + // SELECT + // *, + // strftime("$formatStr", "date") AS month, + // SUM(CASE WHEN "item_type" = 1 THEN "value" ELSE 0 END) OVER (PARTITION BY strftime("$formatStr", "date")) AS expend_total, + // SUM(CASE WHEN "item_type" = 0 THEN "value" ELSE 0 END) OVER (PARTITION BY strftime("$formatStr", "date")) AS income_total + // FROM ${BriefAccountingDdl.tableNameOfBillItem} + // $rangeWhere + // ORDER BY "date" DESC + // LIMIT $pageSize + // OFFSET $offset + // """; + + // sql2语句中的OVER、PARTITION等新特性好像 sqflite: 2.3.2 不支持,而且这个查询很耗性能 + var sql3 = """ + SELECT + b.*, + strftime("$formatStr", "date") AS month, + (SELECT ROUND(SUM(CASE WHEN "item_type" = 1 THEN "value" ELSE 0.0 END), 2) + FROM ${AILightLifeDdl.tableNameOfBillItem} AS sub + WHERE strftime("$formatStr", sub."date") = strftime("$formatStr", b."date")) AS expend_total, + (SELECT ROUND(SUM(CASE WHEN "item_type" = 0 THEN "value" ELSE 0.0 END), 2) + FROM ${AILightLifeDdl.tableNameOfBillItem} AS sub + WHERE strftime("$formatStr", sub."date") = strftime("$formatStr", b."date")) AS income_total + FROM ${AILightLifeDdl.tableNameOfBillItem} AS b + $rangeWhere + ORDER BY b."date" DESC + LIMIT $pageSize + OFFSET $offset + """; + + try { + final rows = await db.rawQuery(sql3); + + // 数据是分页查询的,但这里带上满足条件的一共多少条 + String sql = + 'SELECT COUNT(*) FROM ${AILightLifeDdl.tableNameOfBillItem}'; + if (where.isNotEmpty) { + sql += ' WHERE ${where.join(' AND ')}'; + } + + int totalCount = + Sqflite.firstIntValue(await db.rawQuery(sql, whereArgs)) ?? 0; + + print(sql); + print("whereArgs $whereArgs totalCount $totalCount"); + + var dishes = rows.map((row) => BillItem.fromMap(row)).toList(); + + return CusDataResult(data: dishes, total: totalCount); + } catch (e) { + print("eeeeeeeeeeeeeeee=$e"); + + return CusDataResult(data: [], total: 1); + } + } + + /// 查询当前账单记录中存在的年月数据,供下拉筛选 + Future>> queryMonthList() async { + return (await database).rawQuery( + """ + SELECT DISTINCT strftime('%Y-%m', `date`) AS month + FROM ${AILightLifeDdl.tableNameOfBillItem} + order by `date` DESC + """, + ); + } + + // 账单中存在的日期范围,用筛选 + Future queryDateRangeList() async { + var list = await (await database).rawQuery( + """ + SELECT MIN("date") AS min_date, MAX("date") AS max_date + FROM ${AILightLifeDdl.tableNameOfBillItem} + """, + ); + + // 默认起止范围为当前 + var range = SimplePeriodRange( + minDate: DateTime.now(), + maxDate: DateTime.now(), + ); + + print("------------------$list"); + // 如果有账单记录,则获取到最大最小值 + if (list.isNotEmpty && + list.first["min_date"] != null && + list.first["max_date"] != null) { + range = SimplePeriodRange.fromMap(list.first); + } + return range; + } + + /// 查询月度、年度统计数据 + Future> queryBillCountList({ + // 年度统计year 或者月度统计 month + String? countType, + // 查询日期范围固定为年月日的完整日期格式,只是统计结果时才切分到年或月 + // 所有月度统计2024-04,但起止范围为2024-04-10 ~ 2024-04-15,也只是这5天的范围 + String? startDate, + String? endDate, + }) async { + // 默认是月度统计,除非指定到年度统计 + var formatStr = "%Y-%m"; + if (countType == "year") { + formatStr = "%Y"; + } + + // 默认统计所有,除非有指定范围 + var dateWhere = ""; + if (startDate != null && endDate != null) { + dateWhere = ' "date" BETWEEN "$startDate" AND "$endDate" AND'; + } + + var sql = """ + SELECT + period, + round(SUM(expend_total_value), 2) AS expend_total_value, + round(SUM(income_total_value), 2) AS income_total_value, + CASE + WHEN SUM(income_total_value) = 0.0 THEN 0.0 + ELSE round(SUM(expend_total_value) / NULLIF(SUM(income_total_value), 0.0), 5) + END AS ratio + FROM + (SELECT + strftime("$formatStr", "date") AS period, + CASE WHEN item_type = 1 THEN value ELSE 0.0 END AS expend_total_value, + CASE WHEN item_type = 0 THEN value ELSE 0.0 END AS income_total_value + FROM ${AILightLifeDdl.tableNameOfBillItem} + WHERE $dateWhere item_type IN (0, 1)) AS combined_data + GROUP BY period + ORDER BY period ASC; + """; + + var rows = await (await database).rawQuery(sql); + return rows.map((row) => BillPeriodCount.fromMap(row)).toList(); + } + + // 简单统计每月、每年、每日的收支总计 + Future> querySimpleBillCountList({ + // 年度统计year 或者月度统计 month + String? countType, + // 查询日期范围固定为年月日的完整日期格式,只是统计结果时才切分到年或月 + // 所有月度统计2024-04,但起止范围为2024-04-10 ~ 2024-04-15,也只是这5天的范围 + String? startDate, + String? endDate, + }) async { + // 默认是月度统计,除非指定到年度统计 + var formatStr = "%Y-%m"; + if (countType == "year") { + formatStr = "%Y"; + } else if (countType == "day") { + formatStr = "%Y-%m-%d"; + } + + // 默认统计所有,除非有指定范围 + var dateWhere = ""; + if (startDate != null && endDate != null) { + dateWhere = ' WHERE "date" BETWEEN "$startDate" AND "$endDate" '; + } + + var sql = """ + SELECT + strftime('$formatStr', "date") AS period, + round(SUM(CASE WHEN "item_type" = 1 THEN "value" ELSE 0.0 END), 2) AS expend_total_value, + round(SUM(CASE WHEN "item_type" = 0 THEN "value" ELSE 0.0 END), 2) AS income_total_value + FROM + ${AILightLifeDdl.tableNameOfBillItem} + $dateWhere + GROUP BY period + """; + + var rows = await (await database).rawQuery(sql); + return rows.map((row) => BillPeriodCount.fromMap(row)).toList(); + } + + ///***********************************************/ + /// AI chat 的相关操作 + /// + + // 查询所有对话记录 + Future> queryChatList({ + String? uuid, + String? keyword, + String? cateType = 'aigc', + }) async { + Database db = await database; + + print("对话历史记录查询参数:"); + print("uuid $uuid"); + print("keyword $keyword"); + print("cateType $cateType"); + + final where = []; + final whereArgs = []; + + if (uuid != null) { + where.add('uuid = ?'); + whereArgs.add(uuid); + } + + if (keyword != null) { + where.add('title LIKE ?'); + whereArgs.add("%$keyword%"); + } + + if (cateType != null) { + where.add('chat_type = ?'); + whereArgs.add(cateType); + } + + final rows = await db.query( + AILightLifeDdl.tableNameOfChatHistory, + where: where.isNotEmpty ? where.join(' AND ') : null, + whereArgs: whereArgs.isNotEmpty ? whereArgs : null, + orderBy: "gmt_create DESC", + ); + + return rows.map((row) => ChatSession.fromMap(row)).toList(); + } + + // 删除单条 + Future deleteChatById(String uuid) async => (await database).delete( + AILightLifeDdl.tableNameOfChatHistory, + where: "uuid=?", + whereArgs: [uuid], + ); + + // 新增(只有单个的时候就一个值的数组,理论上不会批量插入) + Future> insertChatList(List chats) async { + var batch = (await database).batch(); + for (var item in chats) { + batch.insert(AILightLifeDdl.tableNameOfChatHistory, item.toMap()); + } + return await batch.commit(); + } + + // 修改单条(只让修改标题其实) + Future updateChatSession(ChatSession item) async => + (await database).update( + AILightLifeDdl.tableNameOfChatHistory, + item.toMap(), + where: 'uuid = ?', + whereArgs: [item.uuid], + ); + + ///***********************************************/ + /// AI 文生图的相关操作 + /// + +// 查询所有记录 + Future> queryTextToImageResultList({ + String? requestId, + String? prompt, + }) async { + Database db = await database; + + print("文生图历史记录查询参数:"); + print("uuid $requestId"); + print("正向提示词关键字 $prompt"); + + final where = []; + final whereArgs = []; + + if (requestId != null) { + where.add('request_id = ?'); + whereArgs.add(requestId); + } + + if (prompt != null) { + where.add('prompt LIKE ?'); + whereArgs.add("%$prompt%"); + } + + final rows = await db.query( + AILightLifeDdl.tableNameOfText2ImageHistory, + where: where.isNotEmpty ? where.join(' AND ') : null, + whereArgs: whereArgs.isNotEmpty ? whereArgs : null, + orderBy: "gmt_create DESC", + ); + + return rows.map((row) => TextToImageResult.fromMap(row)).toList(); + } + + // 删除单条 + Future deleteTextToImageResultById(String requestId) async => + (await database).delete( + AILightLifeDdl.tableNameOfText2ImageHistory, + where: "request_id=?", + whereArgs: [requestId], + ); + + // 新增(只有单个的时候就一个值的数组,理论上不会批量插入) + Future> insertTextToImageResultList( + List rsts) async { + var batch = (await database).batch(); + for (var item in rsts) { + batch.insert( + AILightLifeDdl.tableNameOfText2ImageHistory, + item.toMap(), + ); + } + return await batch.commit(); + } + + ///***********************************************/ + /// dish 的相关操作 + /// + + // 新增多条食物(只有单个的时候就一个值得数组) + Future> insertDishList(List dishes) async { + var batch = (await database).batch(); + for (var item in dishes) { + batch.insert(AILightLifeDdl.tableNameOfDish, item.toMap()); + } + return await batch.commit(); + } + + // 修改单条基础 + Future updateDish(Dish dish) async => (await database).update( + AILightLifeDdl.tableNameOfDish, + dish.toMap(), + where: 'dish_id = ?', + whereArgs: [dish.dishId], + ); + + // 删除单条 + Future deleteDishById(String dishId) async => (await database).delete( + AILightLifeDdl.tableNameOfDish, + where: "dish_id=?", + whereArgs: [dishId], + ); + + // 条件查询食物列表 + Future queryDishList({ + String? dishId, + String? dishName, + List? tags, // 食物的分类和餐次查询为多个,只有一个就一个值的数组 + List? mealCategories, + int? page, + int? pageSize, + }) async { + Database db = await database; + + print("菜品条件查询传入的条件:"); + print("dishId $dishId"); + print("dishName $dishName"); + print("tags $tags"); + print("mealCategories $mealCategories"); + print("page $page"); + print("pageSize $pageSize"); + + // f分页相关处理 + page ??= 1; + pageSize ??= 10; + + final offset = (page - 1) * pageSize; + + final where = []; + final whereArgs = []; + + if (dishId != null) { + where.add('dish_id = ?'); + whereArgs.add(dishId); + } + + if (dishName != null) { + where.add('dish_name LIKE ?'); + whereArgs.add("%$dishName%"); + } + + // 这里应该是内嵌的or + if (tags != null && tags.isNotEmpty) { + for (var tag in tags) { + where.add('tags LIKE ?'); + whereArgs.add("%$tag%"); + } + } + + if (mealCategories != null && mealCategories.isNotEmpty) { + for (var cate in mealCategories) { + where.add('meal_categories LIKE ?'); + whereArgs.add("%$cate%"); + } + } + + final dishRows = await db.query( + AILightLifeDdl.tableNameOfDish, + where: where.isNotEmpty ? where.join(' AND ') : null, + whereArgs: whereArgs.isNotEmpty ? whereArgs : null, + limit: pageSize, + offset: offset, + ); + + // 这个只有食物名称的关键字查询结果 + int? totalCount = Sqflite.firstIntValue( + await db.rawQuery( + 'SELECT COUNT(*) FROM ${AILightLifeDdl.tableNameOfDish} ' + 'WHERE dish_name LIKE ? ', + ['%$dishName%'], + ), + ); + + print('dish Total count: $totalCount, dishRows 长度 ${dishRows.length}'); + + var dishes = dishRows.map((row) => Dish.fromMap(row)).toList(); + + return CusDataResult(data: dishes, total: totalCount ?? 0); + } + + // 随机查询10条数据 + // 主页显示的时候需要,可以传餐次和数量 + Future> queryRandomDishList({String? cate, int? size = 10}) async { + Database db = await database; + + final where = []; + final whereArgs = []; + + if (cate != null) { + where.add('meal_categories like ?'); + whereArgs.add('%$cate%'); + } + + List> randomRows = await db.query( + AILightLifeDdl.tableNameOfDish, + where: where.isNotEmpty ? where.join(' AND ') : null, + whereArgs: whereArgs.isNotEmpty ? whereArgs : null, + orderBy: 'RANDOM()', + limit: size, + ); + + return randomRows.map((row) => Dish.fromMap(row)).toList(); + } +} diff --git a/lib/common/db_tools/ddl_ai_light_life.dart b/lib/common/db_tools/ddl_ai_light_life.dart new file mode 100644 index 0000000..bd4e58c --- /dev/null +++ b/lib/common/db_tools/ddl_ai_light_life.dart @@ -0,0 +1,110 @@ +/// AI Light Life 数据库中相关表的创建 +class AILightLifeDdl { + // db名称 + static String databaseName = "embedded_ai_light_life.db"; + +// 账单条目表 + static const tableNameOfBillItem = 'all_bill_item'; + + static const String ddlForBillItem = """ + CREATE TABLE $tableNameOfBillItem ( + bill_item_id TEXT NOT NULL, + item_type INTEGER NOT NULL, + date TEXT, + category TEXT, + item TEXT NOT NULL, + value REAL NOT NULL, + gmt_modified TEXT NOT NULL, + PRIMARY KEY("bill_item_id") + ); + """; + + /// 2024-06-01 新增AI对话留存 + // 账单条目表 + static const tableNameOfChatHistory = 'all_chat_history'; + + // 2024-06-14 + // 图像理解也有对话,所以新加一个对话类型栏位:aigc、image2text、text2image…… + // i2t_image_path 指图像理解时被参考的图片base64数据 + static const String ddlForChatHistory = """ + CREATE TABLE $tableNameOfChatHistory ( + uuid TEXT NOT NULL, + title TEXT NOT NULL, + gmt_create TEXT NOT NULL, + messages TEXT NOT NULL, + llm_name TEXT NOT NULL, + yun_platform_name TEXT, + i2t_image_path TEXT, + chat_type TEXT NOT NULL, + PRIMARY KEY("uuid") + ); + """; + + /// 2024-06-13 新增文生图简单内容流程 + // 账单条目表 + static const tableNameOfText2ImageHistory = 'all_text2image_history'; + + static const String ddlForText2ImageHistory = """ + CREATE TABLE $tableNameOfText2ImageHistory ( + request_id TEXT NOT NULL, + prompt TEXT NOT NULL, + negative_prompt TEXT, + style TEXT NOT NULL, + image_urls TEXT, + gmt_create TEXT NOT NULL, + PRIMARY KEY("request_id") + ); + """; + + // 菜品基础表 + static const tableNameOfDish = 'all_dish'; + + // 2023-03-10 避免导入时重复导入,还是加一个unique + static const String ddlForDish = """ + CREATE TABLE $tableNameOfDish ( + dish_id TEXT NOT NULL PRIMARY KEY, + dish_name TEXT NOT NULL, + description TEXT, + photos TEXT, + videos TEXT, + tags TEXT, + meal_categories TEXT, + recipe TEXT, + recipe_picture TEXT, + UNIQUE(dish_name,tags) + ); + """; + + /// ---------------------- 下面的暂时简化为上面,如果后续真的记录非常多,再考虑拆分为支出和收入两部分 + + // 创建的表名加上数据库明缩写前缀,避免出现关键字问题 + // 基础活动基础表 + static const tableNameOfExpend = 'all_expend'; + // 动作基础表 + static const tableNameOfIncome = 'all_income'; + + // (预留的)收入和支出的分类不一样,暂时只用一个表,加个栏位来区分时支出还是收入的分类 + static const tableNameOfCategory = 'all_category'; + + static const String ddlForExpend = """ + CREATE TABLE $tableNameOfExpend ( + expend_id INTEGER, NOT NULL, + date TEXT, + category TEXT, + item TEXT NOT NULL, + value REAL NOT NULL, + PRIMARY KEY("expend_id" AUTOINCREMENT) + ); + """; + + static const String ddlForIncome = """ + CREATE TABLE $tableNameOfIncome ( + income_id INTEGER NOT NULL, + date TEXT, + category TEXT, + item TEXT NOT NULL, + value REAL NOT NULL, + PRIMARY KEY("income_id" AUTOINCREMENT) + ); + """; +} diff --git a/lib/common/utils/tools.dart b/lib/common/utils/tools.dart new file mode 100644 index 0000000..9cd09c4 --- /dev/null +++ b/lib/common/utils/tools.dart @@ -0,0 +1,157 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../apis/_self_keys.dart'; +import '../../models/common_llm_info.dart'; +import '../../services/cus_get_storage.dart'; +import '../constants.dart'; + +/// 请求各种权限 +/// 目前存储类的权限要分安卓版本,所以单独处理 +/// 查询安卓媒体存储权限和其他权限不能同时进行 +Future requestPermission({ + bool isAndroidMedia = true, + List? list, +}) async { + // 如果是请求媒体权限 + if (isAndroidMedia) { + // 2024-01-12 Android13之后,没有storage权限了,取而代之的是: + // Permission.photos, Permission.videos or Permission.audio等 + // 参看:https://github.com/Baseflow/flutter-permission-handler/issues/1247 + if (Platform.isAndroid) { + // 获取设备sdk版本 + final androidInfo = await DeviceInfoPlugin().androidInfo; + int sdkInt = androidInfo.version.sdkInt; + + if (sdkInt <= 32) { + PermissionStatus storageStatus = await Permission.storage.request(); + return storageStatus.isGranted; + } else { + Map statuses = await [ + Permission.audio, + Permission.photos, + Permission.videos, + Permission.manageExternalStorage, + ].request(); + + return (statuses[Permission.audio]!.isGranted && + statuses[Permission.photos]!.isGranted && + statuses[Permission.videos]!.isGranted && + statuses[Permission.manageExternalStorage]!.isGranted); + } + } else if (Platform.isIOS) { + Map statuses = await [ + Permission.mediaLibrary, + Permission.storage, + ].request(); + return (statuses[Permission.mediaLibrary]!.isGranted && + statuses[Permission.storage]!.isGranted); + } + // ??? 还差其他平台的 + } + + // 如果有其他权限需要访问,则一一处理(没有传需要请求的权限,就直接返回成功) + list = list ?? []; + if (list.isEmpty) { + return true; + } + Map statuses = await list.request(); + // 如果每一个都授权了,那就返回授权了 + return list.every((p) => statuses[p]!.isGranted); +} + +// 根据数据库拼接的字符串值转回对应选项 +List genSelectedCusLabelOptions( + String? optionsStr, + List cusLabelOptions, +) { + // 如果为空或者空字符串,返回空列表 + if (optionsStr == null || optionsStr.isEmpty || optionsStr.trim().isEmpty) { + return []; + } + + List selectedValues = optionsStr.split(','); + List selectedLabels = []; + + for (String selectedValue in selectedValues) { + for (CusLabel option in cusLabelOptions) { + if (option.value == selectedValue) { + selectedLabels.add(option); + } + } + } + + return selectedLabels; +} + +String getTimePeriod() { + DateTime now = DateTime.now(); + if (now.hour >= 0 && now.hour < 9) { + return '早餐'; + } else if (now.hour >= 9 && now.hour < 11) { + return '早茶'; + } else if (now.hour >= 11 && now.hour < 14) { + return '午餐'; + } else if (now.hour >= 14 && now.hour < 16) { + return '下午茶'; + } else if (now.hour >= 16 && now.hour < 20) { + return '晚餐'; + } else { + return '夜宵'; + } +} + +// 保存指定平台应用配置的id和key +setIdAndKeyFromPlatform(CloudPlatform cp, String? id, String? key) async { + if (cp == CloudPlatform.baidu) { + await MyGetStorage().setBaiduCommonAppId(id); + await MyGetStorage().setBaiduCommonAppKey(key); + } else if (cp == CloudPlatform.aliyun) { + await MyGetStorage().setAliyunCommonAppId(id); + await MyGetStorage().setAliyunCommonAppKey(key); + } else { + await MyGetStorage().setTencentCommonAppId(id); + await MyGetStorage().setTencentCommonAppKey(key); + } +} + +// 通过传入的平台,获取该平台应用配置的id和key +Map getIdAndKeyFromPlatform(CloudPlatform cp) { + String? id; + String? key; + + if (cp == CloudPlatform.baidu) { + // 初始化id或者key + id = MyGetStorage().getBaiduCommonAppId(); + key = MyGetStorage().getBaiduCommonAppKey(); + } else if (cp == CloudPlatform.aliyun) { + id = MyGetStorage().getAliyunCommonAppId(); + key = MyGetStorage().getAliyunCommonAppKey(); + } else { + id = MyGetStorage().getTencentCommonAppId(); + key = MyGetStorage().getTencentCommonAppKey(); + } + return {"id": id, "key": key}; +} + +// 设置私人的平台应用id和key +setDefaultAppIdAndKey() async { + await MyGetStorage().setBaiduCommonAppId(BAIDU_API_KEY); + await MyGetStorage().setBaiduCommonAppKey(BAIDU_SECRET_KEY); + await MyGetStorage().setAliyunCommonAppId(ALIYUN_APP_ID); + await MyGetStorage().setAliyunCommonAppKey(ALIYUN_API_KEY); + await MyGetStorage().setTencentCommonAppId(TENCENT_SECRET_ID); + await MyGetStorage().setTencentCommonAppKey(TENCENT_SECRET_KEY); +} + +// 清除设定好的平台应用id和key +clearAllAppIdAndKey() async { + await MyGetStorage().setBaiduCommonAppId(null); + await MyGetStorage().setBaiduCommonAppKey(null); + await MyGetStorage().setAliyunCommonAppId(null); + await MyGetStorage().setAliyunCommonAppKey(null); + await MyGetStorage().setTencentCommonAppId(null); + await MyGetStorage().setTencentCommonAppKey(null); +} diff --git a/lib/dio_client/cus_http_client.dart b/lib/dio_client/cus_http_client.dart new file mode 100644 index 0000000..9940144 --- /dev/null +++ b/lib/dio_client/cus_http_client.dart @@ -0,0 +1,58 @@ +import 'cus_http_request.dart'; + +// 来源:https://www.cnblogs.com/luoshang/p/16987781.html + +/// 调用底层的request,重新提供get,post等方便方法 + +class HttpUtils { + static HttpRequest httpRequest = HttpRequest(); + + /// get + static Future get({ + required String path, + Map? queryParameters, + bool showLoading = true, + bool showErrorMessage = true, + }) { + return httpRequest.request( + path: path, + method: HttpMethod.get, + queryParameters: queryParameters, + showLoading: showLoading, + showErrorMessage: showErrorMessage, + ); + } + + /// post + static Future post({ + required String path, + required HttpMethod method, + dynamic headers, + dynamic data, + bool showLoading = true, + bool showErrorMessage = true, + }) { + return httpRequest.request( + path: path, + method: HttpMethod.post, + headers: headers, + data: data, + showLoading: showLoading, + showErrorMessage: showErrorMessage, + ); + } +} + +/* +使用方法: +import 'cus_dio_client.dart'; + +HttpUtils.get( +  path: '11111' +); + + HttpUtils.post( +  path: '1111', +  method: HttpMethod.post //可以更改其他的 +); +*/ \ No newline at end of file diff --git a/lib/dio_client/cus_http_options.dart b/lib/dio_client/cus_http_options.dart new file mode 100644 index 0000000..6ca6a03 --- /dev/null +++ b/lib/dio_client/cus_http_options.dart @@ -0,0 +1,11 @@ +// 超时时间 +class HttpOptions { + // 请求地址,这个应该别处传来(使用时带上完整地址即可) + static const String baseUrl = ''; + //单位时间是ms + static const Duration connectTimeout = Duration(milliseconds: 60 * 1000); + static const Duration receiveTimeout = Duration(milliseconds: 5 * 60 * 1000); + static const Duration sendTimeout = Duration(milliseconds: 10 * 1000); + // 自定义content-type + static const String contentType = "application/json;charset=utf-8"; +} diff --git a/lib/dio_client/cus_http_request.dart b/lib/dio_client/cus_http_request.dart new file mode 100644 index 0000000..a99dbf3 --- /dev/null +++ b/lib/dio_client/cus_http_request.dart @@ -0,0 +1,152 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; +import 'package:pretty_dio_logger/pretty_dio_logger.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; + +//辅助配置 +import 'cus_http_options.dart'; +import 'intercepter_response.dart'; +import 'interceptor_error.dart'; +import 'interceptor_request.dart'; + +class HttpRequest { + // 单例模式使用Http类, + static final HttpRequest _instance = HttpRequest._internal(); + + factory HttpRequest() => _instance; + + static late final Dio dio; + + /// 内部构造方法 + HttpRequest._internal() { + // print("*******初始化时候的url:$url"); + + /// 初始化dio + BaseOptions options = BaseOptions( + connectTimeout: HttpOptions.connectTimeout, + receiveTimeout: HttpOptions.receiveTimeout, + sendTimeout: HttpOptions.sendTimeout, + baseUrl: HttpOptions.baseUrl, + contentType: HttpOptions.contentType, + // 2024-03-12 目前需要用的接口就 login成功的返回时text/html,其他的都是application/json。 + // 所以暂时就不全部拍成文本格式了 + // responseType: ResponseType.plain, + ); + + dio = Dio(options); + + // 2024-03-11 因为测试,自签名证书一律放过 + // 参考文档:https://github.com/cfug/dio/blob/main/dio/README-ZH.md#https-%E8%AF%81%E4%B9%A6%E6%A0%A1%E9%AA%8C + dio.httpClientAdapter = IOHttpClientAdapter( + createHttpClient: () { + final client = HttpClient(); + client.badCertificateCallback = ( + X509Certificate cert, + String host, + int port, + ) { + return true; + }; + return client; + }, + ); + + /// 添加各种拦截器 + // 2024-03-11 新的添加多个 + // Add the custom interceptor + dio.interceptors.addAll([ + const RequestInterceptor(), + const ResponseIntercepter(), + ErrorInterceptor(), + PrettyDioLogger( + requestHeader: true, + requestBody: true, + responseHeader: true, + responseBody: true, + // responseBody: false, // 响应太多了,不显示 + maxWidth: 150, + ), + ]); + } + + /// 封装request方法 + Future request({ + required String path, //接口地址 + required HttpMethod method, //请求方式 + Map? headers, // 可以自定义一些header + dynamic data, //数据 + Map? queryParameters, + bool showLoading = true, //加载过程 + bool showErrorMessage = true, //返回数据 + }) async { + const Map methodValues = { + HttpMethod.get: 'get', + HttpMethod.post: 'post', + HttpMethod.put: 'put', + HttpMethod.delete: 'delete', + HttpMethod.patch: 'patch', + HttpMethod.head: 'head' + }; + + //动态添加header头 + // Map headers = {}; + // headers["version"] = "1.0.0"; + + Options options = Options( + method: methodValues[method], + headers: headers, + ); + + try { + if (showLoading) { + //EasyLoading.show(status: 'loading...'); + } + Response response = await HttpRequest.dio.request( + path, + data: data, + queryParameters: queryParameters, + options: options, + ); + + return response.data; + } on DioException catch (error) { + // 2024-03-11 这里是要取得http的错误,但默认类型时Object?,所以要转一下 + // HttpException httpException = error.error; + HttpException httpException = error.error as HttpException; + + print("这里是执行HttpRequest的request()方法在报错:"); + print(httpException); + print(httpException.code); + print(httpException.msg); + print(showErrorMessage); + print("========================"); + + // print("code:${httpException.code}"); + // print("msg:${httpException.msg}"); + if (showErrorMessage) { + EasyLoading.showToast(httpException.msg); + } + + // 2024-06-20 这里还是要把错误抛出去,在请求的API处方便trycatch拦截处理 + // 否则接口处就只看到一个null了 + throw httpException; + } finally { + if (showLoading) { + EasyLoading.dismiss(); + } + } + } +} + +enum HttpMethod { + get, + post, + delete, + put, + patch, + head, +} diff --git a/lib/dio_client/intercepter_response.dart b/lib/dio_client/intercepter_response.dart new file mode 100644 index 0000000..1c2acbd --- /dev/null +++ b/lib/dio_client/intercepter_response.dart @@ -0,0 +1,28 @@ +// ignore_for_file: avoid_print + +import 'package:dio/dio.dart'; + +class ResponseIntercepter extends Interceptor { + const ResponseIntercepter(); + + @override + Future onResponse( + Response response, + ResponseInterceptorHandler handler, + ) async { + print('【onResponse】进入了dio的响应拦截器'); + + // ??? 这里打印response和response.data是一样的 + + // print("************************** ${response}"); + // print("************************** ${response.data}"); + + // 判断返回数据中是否包含"token失效"的信息 + // if (response.data.contains("token失效")) { + // // 导航到登录页面 + // navigatorKey.currentState!.pushReplacementNamed('/login'); + // } + + handler.next(response); + } +} diff --git a/lib/dio_client/interceptor_error.dart b/lib/dio_client/interceptor_error.dart new file mode 100644 index 0000000..282dad0 --- /dev/null +++ b/lib/dio_client/interceptor_error.dart @@ -0,0 +1,192 @@ +// ignore_for_file: avoid_print + +import 'package:dio/dio.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; + +class ErrorInterceptor extends Interceptor { + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + print('【onError】进入了dio的错误拦截器'); + + print("err is :$err"); + + print( + """----------------------- +err 详情 + message: ${err.message} + type: ${err.type} + error: ${err.error} + response: ${err.response} + -----------------------""", + ); + + /// 根据DioError创建HttpException + // HttpException httpException = HttpException.create(err); +// 2024-06-20 上面的create方法有问题,暂时不用 + HttpException httpException = HttpException( + code: 1000, + msg: err.error != null ? err.error.toString() : err.response.toString(), + ); + + /// dio默认的错误实例,如果是没有网络,只能得到一个未知错误,无法精准的得知是否是无网络的情况 + /// 这里对于断网的情况,给一个特殊的code和msg,其他可以识别处理的错误也可以订好 + if (err.type == DioExceptionType.unknown) { + var connectivityResult = await (Connectivity().checkConnectivity()); + + print("connectivityResult这里以前是返回一个,现在是列表里???$connectivityResult"); + + if (connectivityResult.first == ConnectivityResult.none) { + httpException = HttpException(code: -100, msg: 'None Network.'); + } + } + + /// 2024-03-11 旧版本的写法是这样,但会报错,所以下面是新建了一个error + // 将自定义的HttpException + // err.error = httpException; + // // 调用父类,回到dio框架 + // super.onError(err, handler); + + /// 创建一个新的DioException实例,并设置自定义的HttpException + DioException newErr = DioException( + requestOptions: err.requestOptions, + response: err.response, + type: err.type, + error: httpException, + ); + + print("往上抛的newErr:$newErr"); + super.onError(newErr, handler); + + // 2024-03-11 新版本要这样写了吗??? + // handler.next(newErr); + } +} + +// +class HttpException implements Exception { + final int code; + final String msg; + + HttpException({ + this.code = -1, + this.msg = 'unknow error', + }); + + @override + String toString() { + return 'Http Error [$code]: $msg'; + } + + factory HttpException.create(DioException error) { + /// dio异常 + switch (error.type) { + case DioExceptionType.cancel: + { + return HttpException(code: -1, msg: 'request cancel'); + } + case DioExceptionType.connectionTimeout: + { + return HttpException(code: -1, msg: 'connect timeout'); + } + case DioExceptionType.sendTimeout: + { + return HttpException(code: -1, msg: 'send timeout'); + } + case DioExceptionType.receiveTimeout: + { + return HttpException(code: -1, msg: 'receive timeout'); + } + case DioExceptionType.badResponse: + { + try { + int statusCode = error.response?.statusCode ?? 0; + // String errMsg = error.response.statusMessage; + // return ErrorEntity(code: errCode, message: errMsg); + switch (statusCode) { + case 400: + { + return HttpException( + code: statusCode, msg: 'Request syntax error'); + } + case 401: + { + return HttpException( + code: statusCode, msg: 'Without permission'); + } + case 403: + { + return HttpException( + code: statusCode, msg: 'Server rejects execution'); + } + case 404: + { + return HttpException( + code: statusCode, msg: 'Unable to connect to server'); + } + case 405: + { + return HttpException( + code: statusCode, msg: 'The request method is disabled'); + } + case 500: + { + return HttpException( + code: statusCode, msg: 'Server internal error'); + } + case 502: + { + return HttpException( + code: statusCode, msg: 'Invalid request'); + } + case 503: + { + return HttpException( + code: statusCode, msg: 'The server is down.'); + } + case 505: + { + return HttpException( + code: statusCode, msg: 'HTTP requests are not supported'); + } + default: + { + return HttpException( + code: statusCode, + msg: error.response?.statusMessage ?? 'unknow error'); + } + } + } on Exception catch (_) { + return HttpException(code: -1, msg: 'unknow error'); + } + } + default: + { + return HttpException(code: -1, msg: error.message ?? 'unknow error'); + } + } + } +} + +/// 简单的错误拦截示例 +// class ErrorInterceptor extends QueuedInterceptor { +// final Dio dio; + +// ErrorInterceptor(this.dio); + +// @override +// Future onError( +// DioException err, ErrorInterceptorHandler handler) async { +// print('onError is called'); +// try { +// // This should throw an error +// await dio.fetch(err.requestOptions); +// } catch (e) { +// // Why cannot I catch the error here? +// print('onError is called again'); +// } +// handler.next(err); +// } +// } \ No newline at end of file diff --git a/lib/dio_client/interceptor_request.dart b/lib/dio_client/interceptor_request.dart new file mode 100644 index 0000000..9988e87 --- /dev/null +++ b/lib/dio_client/interceptor_request.dart @@ -0,0 +1,22 @@ +// ignore_for_file: avoid_print + +import 'package:dio/dio.dart'; + +class RequestInterceptor extends Interceptor { + const RequestInterceptor(); + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + print('【onRequest】进入了dio的请求拦截器'); + + // 2024-03-11 请求要带 authorization 自定义头 + // 登录的时候要存入缓存,所以请求时要从缓存中拿,如果缓存中没有,采用登录预设的字符串 + + // options.headers['Authorization'] = 'Basic cGhvbmU6cGhvbmU='; + + return handler.next(options); + } +} diff --git a/lib/main.dart b/lib/main.dart index 8e94089..2982053 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,125 +1,121 @@ +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:get_storage/get_storage.dart'; + +import 'views/home_page.dart'; + +GlobalKey navigatorKey = GlobalKey(); void main() { - runApp(const MyApp()); + AppCatchError().run(); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +//全局异常的捕捉 +class AppCatchError { + run() { + ///Flutter 框架异常 + FlutterError.onError = (FlutterErrorDetails details) async { + ///线上环境 todo + if (kReleaseMode) { + Zone.current.handleUncaughtError(details.exception, details.stack!); + } else { + //开发期间 print + FlutterError.dumpErrorToConsole(details); + } + }; - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - useMaterial3: true, - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), + runZonedGuarded( + () { + //受保护的代码块 + WidgetsFlutterBinding.ensureInitialized(); + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]) + .then((_) async { + await GetStorage.init(); + runApp(const AILightLifeApp()); + }); + }, + (error, stack) => catchError(error, stack), ); } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". + ///对搜集的 异常进行处理 上报等等 + catchError(Object error, StackTrace stack) async { + //是否是 Release版本 + debugPrint("AppCatchError>>>>>>>>>> [ kReleaseMode ] $kReleaseMode"); + debugPrint('AppCatchError>>>>>>>>>> [ Message ] $error'); + debugPrint('AppCatchError>>>>>>>>>> [ Stack ] \n$stack'); - final String title; + // 弹窗提醒用户 + EasyLoading.showToast( + error.toString(), + duration: const Duration(seconds: 5), + toastPosition: EasyLoadingToastPosition.top, + ); - @override - State createState() => _MyHomePageState(); + // 判断返回数据中是否包含"token失效"的信息 + // 一些错误处理,比如token失效这里退出到登录页面之类的 + if (error.toString().contains("token无效") || + error.toString().contains("token已过期") || + error.toString().contains("登录出错") || + error.toString().toLowerCase().contains("invalid")) { + print(error); + } + } } -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } +class AILightLifeApp extends StatelessWidget { + const AILightLifeApp({super.key}); @override Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'You have pushed the button this many times:', - ), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), + return ScreenUtilInit( + designSize: const Size(360, 640), // 1080p / 3 ,单位dp + minTextAdapt: true, + splitScreenMode: true, + builder: (_, widget) { + return MaterialApp( + navigatorKey: navigatorKey, + title: 'ai_light_life', + debugShowCheckedModeBanner: false, + // 应用导航的观察者,导航有变化的时候可以做一些事? + // navigatorObservers: [routeObserver], + /// 旧版本不用默认3 + // theme: ThemeData( + // primarySwatch: Colors.blue, + // useMaterial3: false, + // ), + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + // form builder表单验证的多国语言 + FormBuilderLocalizations.delegate, + ], + supportedLocales: const [ + Locale('zh', 'CN'), + Locale('en', 'US'), + ...FormBuilderLocalizations.supportedLocales, ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. + // 初始化的locale + locale: const Locale('zh', 'CN'), + + /// 默认的主题 + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: const HomePage(), + builder: EasyLoading.init(), + ); + }, ); } } diff --git a/lib/models/ai_interface_state/aliyun_qwenvl_state.dart b/lib/models/ai_interface_state/aliyun_qwenvl_state.dart new file mode 100644 index 0000000..1cb9765 --- /dev/null +++ b/lib/models/ai_interface_state/aliyun_qwenvl_state.dart @@ -0,0 +1,274 @@ +// To parse this JSON data, do +// +// final aliyunQwenVlReq = aliyunQwenVlReqFromJson(jsonString); + +import 'dart:convert'; + +/// ================================================= +/// 千问VL的请求和响应可以通用的部分 +/// ================================================= +class QwenVLMessage { + String role; + List content; + + QwenVLMessage({required this.role, required this.content}); + + factory QwenVLMessage.fromJson(Map json) => QwenVLMessage( + role: json["role"], + content: List.from( + json["content"]!.map((x) => QwenVLContent.fromJson(x)), + ), + ); + + Map toJson() => { + "role": role, + "content": List.from(content.map((x) => x.toJson())), + }; +} + +// 千问vm的输入和输出都可以用这个,区别在于响应不带image,请求可以有image去做视觉理解 +class QwenVLContent { + String? text; + String? image; + + QwenVLContent({this.text, this.image}); + + factory QwenVLContent.fromJson(Map json) => QwenVLContent( + text: json["text"], + image: json["image"], + ); + + Map toJson() => { + "text": text, + "image": image, + }; +} + +/// ================================================= +/// 千问VL的请求体 +/// ================================================= +AliyunQwenVlReq aliyunQwenVlReqFromJson(String str) => + AliyunQwenVlReq.fromJson(json.decode(str)); + +String aliyunQwenVlReqToJson(AliyunQwenVlReq data) => + json.encode(data.toJson()); + +class AliyunQwenVlReq { + String model; + QwenVLInput input; + QwenVLParameters? parameters; + + AliyunQwenVlReq({ + required this.model, + required this.input, + this.parameters, + }); + + factory AliyunQwenVlReq.fromJson(Map json) => + AliyunQwenVlReq( + model: json["model"], + input: QwenVLInput.fromJson(json["input"]), + parameters: json["parameters"] == null + ? null + : QwenVLParameters.fromJson(json["parameters"]), + ); + + Map toJson() => { + "model": model, + "input": input.toJson(), + "parameters": parameters?.toJson(), + }; + + Map toSimpleJson(isStream) => { + "model": model, + "input": input.toJson(), + "parameters": parameters?.toSimpleJson(isStream), + }; +} + +class QwenVLInput { + List messages; + + QwenVLInput({required this.messages}); + + factory QwenVLInput.fromJson(Map json) => QwenVLInput( + messages: List.from( + json["messages"]!.map((x) => QwenVLMessage.fromJson(x))), + ); + + Map toJson() => { + "messages": List.from(messages.map((x) => x.toJson())), + }; +} + +class QwenVLParameters { + // 2024-06-15 阿里云的请求这个parameters不能为null,但上面的在零一万物等模型中不存在, + // 所以这里一个对话模型可能都有的参数 + // 控制生成结果的随机性。数值越小,随机性越弱;数值越大,随机性越强。 + // 取值范围: [.0f, 1.0f]。 多样性,越高,多样性越好, 缺省 0.3。 + double? topP; + // 生成时,采样候选集的大小。 + // 取值越大,生成的随机性越高;取值越小,生成的确定性越高。注意:如果top_k的值大于100,top_k将采用默认值100 + double? topK; + int? seed; + // 流式响应时,是否增量输出 + // 默认为false,即后面的内容会包含已经输出的内容,不用手动叠加 + bool? incrementalOutput; + + QwenVLParameters({ + this.topP, + this.topK, + this.seed, + this.incrementalOutput, + }); + + factory QwenVLParameters.fromJson(Map json) => + QwenVLParameters( + topP: json["top_p"], + topK: json["top_K"], + seed: json["seed"], + incrementalOutput: json["incremental_output"], + ); + + Map toJson() => { + "top_p": topP, + "top_K": topK, + "seed": seed, + "incremental_output": incrementalOutput, + }; + + Map toSimpleJson(bool sse) => + sse ? {"incremental_output": incrementalOutput} : {}; +} + +/// ================================================= +/// 千问VL的响应体 +/// 和通用的CommonRespBody 更少的参数,output的内容也不一样(主要是message的结构) +/// ================================================= +AliyunQwenVlResp aliyunQwenVlRespFromJson(String str) => + AliyunQwenVlResp.fromJson(json.decode(str)); + +String aliyunQwenVlRespToJson(AliyunQwenVlResp data) => + json.encode(data.toJson()); + +class AliyunQwenVlResp { + QwenVLOutput? output; + QwenVLUsage? usage; + String? requestId; + + // 自己处理后直接拿的输出结果 + String customReplyText; + + // 错误码和错误消息 + String? errorCode; + String? errorMsg; + + AliyunQwenVlResp({ + this.output, + this.usage, + this.requestId, + required this.customReplyText, + this.errorCode, + this.errorMsg, + }); + + factory AliyunQwenVlResp.fromJson(Map json) { + var customReplyText = ""; + + // 流式的时候结构不一样?content从list变为string了??? + if (json["output"] != null) { + // 2024-06-15 阿里的也有2种类型:一是一般text的格式,另一种是message的格式,理论上2者不会同时存在 + var temp = QwenVLOutput.fromJson(json["output"]); + + if (temp.choices.isNotEmpty) { + var a = temp.choices.first.message.content; + + for (var e in a) { + customReplyText += e.text ?? ""; + } + } + } + + return AliyunQwenVlResp( + output: + json["output"] == null ? null : QwenVLOutput.fromJson(json["output"]), + usage: json["usage"] == null ? null : QwenVLUsage.fromJson(json["usage"]), + requestId: json["request_id"], + errorCode: json["code"], + errorMsg: json["message"], + customReplyText: customReplyText, + ); + } + + Map toJson() => { + "output": output?.toJson(), + "usage": usage?.toJson(), + "request_id": requestId, + "code": errorCode, + "message": errorMsg, + "custom_reply_text": customReplyText, + }; +} + +class QwenVLOutput { + List choices; + + QwenVLOutput({ + required this.choices, + }); + + factory QwenVLOutput.fromJson(Map json) => QwenVLOutput( + choices: List.from( + json["choices"]!.map((x) => QwenVLChoice.fromJson(x)), + ), + ); + + Map toJson() => { + "choices": List.from(choices.map((x) => x.toJson())), + }; +} + +class QwenVLChoice { + String? finishReason; + QwenVLMessage message; + + QwenVLChoice({ + this.finishReason, + required this.message, + }); + + factory QwenVLChoice.fromJson(Map json) => QwenVLChoice( + finishReason: json["finish_reason"], + message: QwenVLMessage.fromJson(json["message"]), + ); + + Map toJson() => { + "finish_reason": finishReason, + "message": message.toJson(), + }; +} + +// 和通用的优点区别:token没有total,多一个image +class QwenVLUsage { + int? outputTokens; + int? inputTokens; + int? imageTokens; + + QwenVLUsage({ + this.outputTokens, + this.inputTokens, + this.imageTokens, + }); + + factory QwenVLUsage.fromJson(Map json) => QwenVLUsage( + outputTokens: json["output_tokens"], + inputTokens: json["input_tokens"], + imageTokens: json["image_tokens"], + ); + + Map toJson() => { + "output_tokens": outputTokens, + "input_tokens": inputTokens, + "image_tokens": imageTokens, + }; +} diff --git a/lib/models/ai_interface_state/aliyun_text2image_state.dart b/lib/models/ai_interface_state/aliyun_text2image_state.dart new file mode 100644 index 0000000..af6ab35 --- /dev/null +++ b/lib/models/ai_interface_state/aliyun_text2image_state.dart @@ -0,0 +1,286 @@ +import 'dart:convert'; + +/// +/// 2024-06-12 以阿里的通义万相系列 API为蓝本的请求响应模型 +/// +class AliyunTextToImgReq { + String? model; + Input? input; + Parameters? parameters; + + AliyunTextToImgReq({ + this.model, // 指明需要调用的模型,固定值wanx-v1 + this.input, + this.parameters, + }); + + factory AliyunTextToImgReq.fromRawJson(String str) => + AliyunTextToImgReq.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory AliyunTextToImgReq.fromJson(Map json) => + AliyunTextToImgReq( + model: json["model"], + input: json["input"] == null ? null : Input.fromJson(json["input"]), + parameters: json["parameters"] == null + ? null + : Parameters.fromJson(json["parameters"]), + ); + + Map toJson() => { + "model": model, + "input": input?.toJson(), + "parameters": parameters?.toJson(), + }; +} + +class Input { + // 描述画面的提示词信息。支持中英文,长度不超过500个字符,超过部分会自动截断 + String? prompt; + // 画面中不想出现的内容描述词信息。支持中英文,长度不超过500个字符,超过部分会自动截断。 + String? negativePrompt; + // 输入参考图像的URL;图片格式可为 jpg,png,tiff,webp等常见位图格式。默认为空。 + String? refImg; + + Input({this.prompt, this.negativePrompt, this.refImg}); + + factory Input.fromRawJson(String str) => Input.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory Input.fromJson(Map json) => Input( + prompt: json["prompt"], + negativePrompt: json["negative_prompt"], + refImg: json["ref_img"], + ); + + Map toJson() => { + "prompt": prompt, + "negative_prompt": negativePrompt, + "ref_img": refImg, + }; +} + +class Parameters { + // 输出图像的风格,目前支持以下风格取值(注意要有尖括号): + // "":默认; "<3d cartoon>":3D卡通; "":动画; "":油画 + // "":水彩; "" :素描;"":中国画; "":扁平插画 + String? style; + // 生成图像的分辨率,目前仅支持'1024*1024','720*1280','1280*720'三种分辨率,默认为1024*1024像素。 + String? size; + // 本次请求生成的图片数量,目前支持1~4张,默认为1。 + int? n; + // 图片生成时候的种子值,取值范围为(0, 4294967290) 。如果不提供,则算法自动用一个随机生成的数字作为种子, + // 如果给定了,则根据 batch 数量分别生成 seed,seed+1,seed+2,seed+3为参数的图片。 + int? seed; + // 期望输出结果与垫图(参考图)的相似度,取值范围[0.0, 1.0],数字越大,生成的结果与参考图越相似 + double? strength; + // 垫图(参考图)生图使用的生成方式,可选值为'repaint' (默认) 和 'refonly'; 其中 repaint代表参考内容,refonly代表参考风格 + String? refMode; + + Parameters({ + this.style, + this.size, + this.n, + this.seed, + this.strength, + this.refMode, + }); + + factory Parameters.fromRawJson(String str) => + Parameters.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory Parameters.fromJson(Map json) => Parameters( + style: json["style"], + size: json["size"], + n: json["n"], + seed: json["seed"], + strength: json["strength"]?.toDouble(), + refMode: json["ref_mode"], + ); + + Map toJson() => { + "style": style, + "size": size, + "n": n, + "seed": seed, + "strength": strength, + "ref_mode": refMode, + }; +} + +/// +/// 2024-06-12 以阿里的通义万相系列 API为蓝本的响应模型 +/// +class AliyunTextToImgResp { + // 本次请求的系统唯一码。 + String? requestId; + // 响应的结果 + Output? output; + // 成功的话会带上消耗的请求数 + Usage? usage; + // 状态码 + String? statusCode; + // 错误代号和信息 + String? code; + String? message; + + AliyunTextToImgResp({ + this.requestId, + this.output, + this.usage, + this.statusCode, + this.code, + this.message, + }); + + factory AliyunTextToImgResp.fromRawJson(String str) => + AliyunTextToImgResp.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory AliyunTextToImgResp.fromJson(Map json) => + AliyunTextToImgResp( + requestId: json["request_id"], + output: json["output"] == null ? null : Output.fromJson(json["output"]), + usage: json["usage"] == null ? null : Usage.fromJson(json["usage"]), + statusCode: json["status_code"], + code: json["code"], + message: json["message"], + ); + + Map toJson() => { + "request_id": requestId, + "output": output?.toJson(), + "usage": usage?.toJson(), + "status_code": statusCode, + "code": code, + "message": message, + }; +} + +/// 文生图默认响应用到这个,只有taskId和status,但作业任务状态查询和结果获取接口可以有更多的属性 +class Output { + // 本次请求的异步任务的作业 id,实际作业结果需要通过异步任务查询接口获取。 + String? taskId; + // 提交异步任务后的作业状态 + String? taskStatus; + // 有成功的图片,就会存在这里;如果是多个中部分成功,这里面还会有报错信息 + List? results; + // 作业中每个batch任务的状态: + // TOTAL:总batch数目、SUCCEEDED:已经成功的batch数目、FAILED:已经失败的batch数目 + TaskMetrics? taskMetrics; + // 整个请求全都出错,则也会带上错误代号和信息 + String? code; + String? message; + + Output({ + this.taskId, + this.taskStatus, + this.results, + this.taskMetrics, + this.code, + this.message, + }); + + factory Output.fromRawJson(String str) => Output.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory Output.fromJson(Map json) => Output( + taskId: json["task_id"], + taskStatus: json["task_status"], + results: json["results"] == null + ? [] + : List.from( + json["results"]!.map((x) => Result.fromJson(x))), + taskMetrics: json["task_metrics"] == null + ? null + : TaskMetrics.fromJson(json["task_metrics"]), + code: json["code"], + message: json["message"], + ); + + Map toJson() => { + "task_id": taskId, + "task_status": taskStatus, + "results": results == null + ? [] + : List.from(results!.map((x) => x.toJson())), + "task_metrics": taskMetrics?.toJson(), + "code": code, + "message": message, + }; +} + +class Result { + String? url; + String? code; + String? message; + + Result({ + this.url, + this.code, + this.message, + }); + + factory Result.fromRawJson(String str) => Result.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory Result.fromJson(Map json) => Result( + url: json["url"], + code: json["code"], + message: json["message"], + ); + + Map toJson() => { + "url": url, + "code": code, + "message": message, + }; +} + +class TaskMetrics { + int? total; + int? succeeded; + int? failed; + + TaskMetrics({this.total, this.succeeded, this.failed}); + + factory TaskMetrics.fromRawJson(String str) => + TaskMetrics.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory TaskMetrics.fromJson(Map json) => TaskMetrics( + total: json["TOTAL"], + succeeded: json["SUCCEEDED"], + failed: json["FAILED"], + ); + + Map toJson() => { + "TOTAL": total, + "SUCCEEDED": succeeded, + "FAILED": failed, + }; +} + +class Usage { + // 本次请求成功生成的图片张数。 + int? imageCount; + + Usage({this.imageCount}); + + factory Usage.fromRawJson(String str) => Usage.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory Usage.fromJson(Map json) => + Usage(imageCount: json["image_count"]); + + Map toJson() => {"image_count": imageCount}; +} diff --git a/lib/models/ai_interface_state/baidu_fuyu8b_state.dart b/lib/models/ai_interface_state/baidu_fuyu8b_state.dart new file mode 100644 index 0000000..b6c63d0 --- /dev/null +++ b/lib/models/ai_interface_state/baidu_fuyu8b_state.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; + +import 'platform_aigc_commom_state.dart'; + +/// +/// 在百度云平台的图像理解模型Fuyu-8B 的请求响应 +/// + +// 请求 +class BaiduFuyu8BReq { + // 请求信息,需要对图像进行发问的内容 + String prompt; + // 图片base64数据 + String image; + // 是否以流式接口的形式返回数据,默认false + bool? stream; + // 还有很多可选参数,和对话的接口是类似的,暂时不用了 + + BaiduFuyu8BReq({ + required this.prompt, + required this.image, + this.stream = false, + }); + + factory BaiduFuyu8BReq.fromRawJson(String str) => + BaiduFuyu8BReq.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory BaiduFuyu8BReq.fromJson(Map json) => BaiduFuyu8BReq( + prompt: json["prompt"], + image: json["image"], + stream: json["stream"], + ); + + Map toJson() => { + "prompt": prompt, + "image": image, + "stream": stream, + }; +} + +// 响应 +class BaiduFuyu8BResp { + // 本轮对话的id + String? id; + // 回包类型。 completion:文本生成返回 + String? object; + // 时间戳 + int? created; + // 表示当前子句的序号。只有在流式接口模式下会返回该字段 + int? sentenceId; + // 表示当前子句是否是最后一句。只有在流式接口模式下会返回该字段 + bool? isEnd; + // 对话返回结果(非流式直接取值) + String? result; + // 说明:· 1:表示输入内容无安全风险· 0:表示输入内容有安全风险 + int? isSafe; + // token统计信息(和对话中使用的是一样的结构) + CommonUsage? usage; + // 错误码 + String? errorCode; + // 错误描述信息,帮助理解和解决发生的错误 + String? errorMsg; + + BaiduFuyu8BResp({ + this.id, + this.object, + this.created, + this.sentenceId, + this.isEnd, + this.result, + this.isSafe, + this.usage, + this.errorCode, + this.errorMsg, + }); + + factory BaiduFuyu8BResp.fromRawJson(String str) => + BaiduFuyu8BResp.fromJson(json.decode(str)); + + String toRawJson() => json.encode(toJson()); + + factory BaiduFuyu8BResp.fromJson(Map json) => + BaiduFuyu8BResp( + id: json["id"], + object: json["object"], + created: json["created"], + sentenceId: json["sentence_id"], + isEnd: json["is_end"], + result: json["result"], + isSafe: json["is_safe"], + usage: + json["usage"] == null ? null : CommonUsage.fromJson(json["usage"]), + errorCode: json["error_code"]?.toString(), + errorMsg: json["error_msg"], + ); + + Map toJson() => { + "id": id, + "object": object, + "created": created, + "sentence_id": sentenceId, + "is_end": isEnd, + "result": result, + "is_safe": isSafe, + "usage": usage?.toJson(), + "error_code": errorCode, + "error_msg": errorMsg, + }; +} diff --git a/lib/models/ai_interface_state/platform_aigc_commom_state.dart b/lib/models/ai_interface_state/platform_aigc_commom_state.dart new file mode 100644 index 0000000..72a30b1 --- /dev/null +++ b/lib/models/ai_interface_state/platform_aigc_commom_state.dart @@ -0,0 +1,571 @@ +import 'dart:convert'; + +/// +/// 阿里云、百度和其他一般第三方常规的aigc(大模型文本对话)返回的消息列表都是role和content。 +/// 腾讯其所有json请求或响应的body,都是首字母全大写的Pascal命名,其他的就是全小写加下划线的snake命名。 +/// + +class CommonMessage { + String role; + String content; + + CommonMessage({ + required this.role, + required this.content, + }); + + factory CommonMessage.fromRawJson(String str) => + CommonMessage.fromJson(json.decode(str)); + + // 为了通用,都带上命名方式,默认就snake,可传pascal + String toRawJson({String? caseType}) => + json.encode(toJson(caseType: caseType)); + + factory CommonMessage.fromJson(Map json) => CommonMessage( + role: json["role"] ?? json["Role"], + content: json["content"] ?? json["Content"], + ); + + Map toJson({String? caseType}) { + return caseType?.toLowerCase() == "pascal" + ? {"Role": role, "Content": content} + : {"role": role, "content": content}; + } +} + +/// +/// 3个平台的usage都不一样,比较麻烦 +/// 所以索性就所有参数都带上,都可选这样来兼容 +/// +class CommonUsage { + int? promptTokens; + int? completionTokens; + int? totalTokens; + int? inputTokens; + int? outputTokens; + + CommonUsage({ + this.promptTokens, + this.completionTokens, + this.totalTokens, + this.inputTokens, + this.outputTokens, + }); + + factory CommonUsage.fromJson(Map json) { + return CommonUsage( + promptTokens: json["PromptTokens"] ?? json["prompt_tokens"], + completionTokens: json["CompletionTokens"] ?? json["completion_tokens"], + totalTokens: json["TotalTokens"] ?? json["total_tokens"], + inputTokens: json["input_tokens"], + outputTokens: json["output_tokens"], + ); + } + + // ???应该没用到 + Map toJson() { + return { + "PromptTokens": promptTokens, + "CompletionTokens": completionTokens, + "TotalTokens": totalTokens, + "input_tokens": inputTokens, + "output_tokens": outputTokens, + }; + } + + @override + String toString() { + // 2024-06-03 这个对话会被作为string存入数据库,然后再被读取转型为ChatMessage。 + // 所以需要是个完整的json字符串,一般fromMap时可以处理 + return ''' + { + "promptTokens": "$promptTokens", + "completionTokens": $completionTokens, + "totalTokens": "$totalTokens", + "inputTokens": "$inputTokens", + "outputTokens": "$outputTokens" + } + '''; + } +} + +/// +/// 百度的响应结果直接是result栏位 +/// 注意:腾讯的choice流式时取值Delta,非流式才取值Message,但两者结构是一样的,都是Role和Content +/// +class CommonChoice { + CommonMessage message; + String finishReason; + int? index; + + CommonChoice({ + required this.message, + required this.finishReason, + this.index, + }); + + factory CommonChoice.fromJson(Map json) => CommonChoice( + // 注意:腾讯的choice流式时取值Delta,非流式才取值Message,但两者结构是一样的,都是Role和Content + // 阿里的没有刻意区分 + message: CommonMessage.fromJson( + json["message"] ?? json["Message"] ?? json["Delta"], + ), + finishReason: json["finish_reason"] ?? json["FinishReason"], + index: json["index"], + ); + + Map toJson({String? caseType}) => + caseType?.toLowerCase() == "pascal" + ? { + "Message": message.toJson(), + "FinishReason": finishReason, + "index": index, + } + : { + "message": message.toJson(), + "finish_reason": finishReason, + "index": index, + }; +} + +/// +/// 阿里云的请求响应参数层级还多一点 +/// 入参 input、parameters 还有单独的东西 +/// 出参 output 等 +class AliyunInput { + List? messages; + + AliyunInput({this.messages}); + + factory AliyunInput.fromJson(Map json) => AliyunInput( + messages: json["messages"] == null + ? [] + : List.from( + json["messages"]!.map((x) => CommonMessage.fromJson(x))), + ); + + Map toJson() => { + "messages": messages == null + ? [] + : List.from(messages!.map((x) => x.toJson())), + }; +} + +class AliyunParameters { + int? seed; + String? resultFormat; + // 流式响应时,是否增量输出 + // 默认为false,即后面的内容会包含已经输出的内容,不用手动叠加 + bool? incrementalOutput; + // 2024-06-15 阿里云的请求这个parameters不能为null,但上面的在零一万物等模型中不存在, + // 所以这里一个对话模型可能都有的参数 + // 控制生成结果的随机性。数值越小,随机性越弱;数值越大,随机性越强。 + // 取值范围: [.0f, 1.0f]。 多样性,越高,多样性越好, 缺省 0.3。 + double? topP; + + AliyunParameters({ + this.seed, + this.resultFormat, + this.incrementalOutput, + this.topP = 0.7, + }); + + factory AliyunParameters.fromJson(Map json) => + AliyunParameters( + seed: json["seed"], + resultFormat: json["result_format"], + incrementalOutput: json["incremental_output"], + topP: json["top_p"], + ); + + Map toJson() => { + "seed": seed, + "result_format": resultFormat, + "incremental_output": incrementalOutput, + "top_p": topP, + }; +} + +class AliyunOutput { + // 入参result_format=text时候的返回值 + String? text; + String? finishReason; + // 入参result_format=message时候的返回值 + List? choices; + + AliyunOutput({ + this.text, + this.finishReason, + this.choices, + }); + + factory AliyunOutput.fromJson(Map json) => AliyunOutput( + text: json["text"], + finishReason: json["finish_reason"], + choices: json["choices"] == null + ? [] + : List.from( + json["choices"]!.map((x) => CommonChoice.fromJson(x))), + ); + + Map toJson() => { + "text": text, + "finish_reason": finishReason, + "choices": choices == null + ? [] + : List.from(choices!.map((x) => x.toJson())), + }; +} + +/// +/// ============================================================================ +/// aigc传参,都是最小的、必传的,其他都预设(一般人也不会调) +/// 注意,都使用非流式的回复 +///============================================================================= + +CommonReqBody commonReqBodyFromJson(String str) => + CommonReqBody.fromJson(json.decode(str)); + +String commonReqBodyToJson(CommonReqBody data, {String? caseType}) => + json.encode(data.toJson(caseType: caseType)); + +class CommonReqBody { + // [腾讯、阿里云]需要显示指定模型名称 + String? model; + // [腾讯、百度] 百度只需要这一个,模型在构建authorition就处理了 + List? messages; + // [腾讯、百度] 是否流式返回(响应会更快,响应默认是增量模式,需要自行拼接;阿里的在自己的parameters中配置) + bool? stream; + // [阿里云] 把消息体单独再放到一个input类中,该类还可以有其他属性 + AliyunInput? input; + // [阿里云] 额外的,阿里还可以配置其他参数,比较有用的是指定输出消息的格式,阿里云不同模型可能默认响应不一样 + AliyunParameters? parameters; + + CommonReqBody({ + this.model, + this.messages, + this.stream = false, + this.input, + this.parameters, + }); + + factory CommonReqBody.fromRawJson(String str) => + CommonReqBody.fromJson(json.decode(str)); + + String toRawJson({String? caseType}) => + json.encode(toJson(caseType: caseType)); + + factory CommonReqBody.fromJson(Map json) => CommonReqBody( + // 腾讯请求应该不用传这个 + model: json["model"] ?? json["Model"], + // 注意腾讯是帕斯卡命名,但只需要关注这一个栏位即可 ///???? + messages: List.from( + ((json["messages"] ?? json["Messages"]) as List) + .map((x) => CommonMessage.fromJson(x)), + ), + stream: json["stream"] ?? json["Stream"] ?? false, + input: + json["input"] == null ? null : AliyunInput.fromJson(json["input"]), + parameters: json["parameters"] == null + ? null + : AliyunParameters.fromJson(json["parameters"]), + ); + + Map toJson({String? caseType}) { + /// ???? 有问题,传入不支持的参数会报错?? + return caseType?.toLowerCase() == "pascal" + ? { + "Model": model, + "Messages": messages + ?.map( + (e) => e.toJson(caseType: "pascal"), + ) + .toList(), + "Stream": stream, + } + : { + "model": model, + "messages": messages, + "stream": stream, + "input": input?.toJson(), + "parameters": parameters?.toJson(), + }; + } +} + +/// +/// aigc 的响应,自然就是越多越好,那最多就是把3个平台的所有值都当做了参数 +/// 不过可能在取值显示的时候,可能不通用。 +/// 比如百度的结果是result,腾讯是choices的某条message,阿里在choices外面还有一层output +/// 这个就要尽量全一点了,取值更方便 +/// +/* +/// 百度文心lite的响应 +{ + id: "as-b8fxqv4yht", + object: "chat.completion", + created: 1717662144, + result: "你好!有什么我可以帮助你的吗?", + is_truncated: false, + need_clear_history: false, + usage: {prompt_tokens: 1, completion_tokens: 8, total_tokens: 9} +} +/// 腾讯混元lite的响应 +{ + Response: { + RequestId: "1881a408-b517-4ee8-83db-b291e265dbb8", + Note: "以上内容为AI生成,不代表开发者立场,请勿删除或修改本标记", + Choices: [{Message: {Role: assistant, Content: 你好!很高兴为您提供帮助,请问您有什么问题?}, FinishReason: stop}] + Created: 1717662179, + Id: "1881a408-b517-4ee8-83db-b291e265dbb8", + Usage: {PromptTokens: 3, CompletionTokens: 12, TotalTokens: 15} + } +} +/// 阿里云千问开源的响应 +{ + output: { + choices: [{finish_reason: stop, message: {role: assistant, content: 你好!有什么我可以帮助你的吗?}}] + } + usage: {total_tokens: 9, output_tokens: 8, input_tokens: 1}, + request_id: "ca2f16c4-661e-9ffb-af92-59e393c138b5" + } +*/ +CommonRespBody commonRespBodyFromJson(String str) => + CommonRespBody.fromJson(json.decode(str)); + +String commonRespBodyToJson(CommonRespBody data) => json.encode(data.toJson()); + +class CommonRespBody { + /// + /// 百度千帆大模型 对话Chat + /// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/6ltgkzya5 + /// id、object、created、sentence_id、is_end、is_truncated、result + /// need_clear_history、ban_round、usage + /// + /// 腾讯混元生文(首字母大写) + /// https://cloud.tencent.com/document/api/1729/105701 + /// Created、Usage、Note、Id、Choices、ErrorMsg、RequestId + /// + /// 阿里云通义千问等部分 开源文本(入参result_format=message时候的返回值) + /// https://help.aliyun.com/document_detail/2712575.html#58595f510c3zl + /// + /// request_id、usage、output(text、finish_reason、choise) + /// 入参result_format=text时,output的内容单独2个栏位在外层:text、finish_reason + /// + /// + + /// 已经通用的、或者可以想办法通用的 + // token统计信息 + CommonUsage? usage; + // 本轮对话的 ID。 + String? id; + // 唯一请求 ID,由服务端生成,每次请求都会返回(若请求因其他原因未能抵达服务端,则该次请求不会获得 RequestId)。 + //定位问题时需要提供该次请求的 RequestId。 + //本接口为流式响应接口,当请求成功时,RequestId 会被放在 HTTP 响应的 Header "X-TC-RequestId" 中。 + String? requestId; + // 创建的 Unix 时间戳,单位为秒 + int? created; + // 错误码和错误消息 + String? errorCode; + String? errorMsg; + // 腾讯的还嵌一层 + TencentError? tencentError; + + /// 回复内容的主体(各不相同) + // 响应的状态 + int? statusCode; + // 百度响应内容 + String? result; + // 腾讯响应内容 + List? choices; + // 阿里云响应内容 + AliyunOutput? output; + + /// 2024-06-06 3个不同的搞成一样的??? 我现在是需要用到显示的值,其他的都暂时不考虑 + String customReplyText; + + /// [百度 特有] + // 回包类型:chat.completion:多轮对话返回 + String? object; + // 当前生成的结果是否被截断 + bool? isTruncated; + // 表示用户输入是否存在安全风险,是否关闭当前会话,清理历史会话信息。 + // true:是,表示用户输入存在安全风险,建议关闭当前会话,清理历史会话信息。 + // false:否,表示用户输入无安全风险 + bool? needClearHistory; + // 当need_clear_history为true时,此字段会告知第几轮对话有敏感信息, + // 如果是当前问题,ban_round=-1 + int? banRound; + // 表示当前子句的序号。只有在流式接口模式下会返回该字段 + int? sentenceId; + // 表示当前子句是否是最后一句。只有在流式接口模式下会返回该字段 + bool? isEnd; + // [腾讯 特有] 免责声明。 + String? note; + + CommonRespBody({ + /// 尽量通用 + this.usage, + this.id, + this.requestId, + this.created, + this.errorCode, + this.errorMsg, + this.tencentError, + + /// 消息主体 + this.statusCode, + this.result, + this.choices, + this.output, + required this.customReplyText, + + /// 一些特有 + this.object, + this.isTruncated, + this.needClearHistory, + this.banRound, + this.sentenceId, + this.isEnd, + this.note, + }); + + // 同步模式下,响应参数为以上字段的完整json包。 + // Created、Usage、Note、Id、Choices、ErrorMsg、RequestId + factory CommonRespBody.fromJson(Map json) { + var customReplyText = "<未取得数据……>"; + + /// 理论上,这三个只会存在一个有值 + // 百度的基础消息 + if (json["result"] != null) { + customReplyText = json["result"]; + } + // 阿里的基础消息 + if (json["output"] != null) { + // 2024-06-15 阿里的也有2种类型:一是一般text的格式,另一种是message的格式,理论上2者不会同时存在 + var temp = AliyunOutput.fromJson(json["output"]); + if (temp.text != null) { + customReplyText = temp.text!; + } + if (temp.choices != null && temp.choices!.isNotEmpty) { + customReplyText = temp.choices!.first.message.content; + } + } + // 腾讯的基础消息 + if (json["Choices"] != null) { + var temp = List.from( + json["Choices"]!.map((x) => CommonChoice.fromJson(x)), + ); + if (temp.isNotEmpty) { + customReplyText = temp.first.message.content; + } + } + // 注意,如果是流式返回,每个都有note,拼在一起会有问题 + // if (json["Note"] != null) { + // customReplyText += "\n\n\n\n**${json["Note"]}**"; + // } + + /// 报错信息很重要,先判断没有报错才显示正文回复的 + var errorCode = json["error_code"] ?? json["code"]; + var errorMsg = json["error_msg"] ?? json["message"]; + if (json["Error"] != null) { + var temp = TencentError.fromJson(json["Error"]); + errorCode = temp.code; + errorMsg = temp.message; + } + + return CommonRespBody( + /// 公共 + usage: (json["Usage"] == null && json["usage"] == null) + ? null + : (json["usage"] != null + ? CommonUsage.fromJson(json["usage"]) + : CommonUsage.fromJson(json["Usage"])), + id: json["id"] ?? json["Id"], + requestId: json["request_id"] ?? json["RequestId"], + created: json["created"] ?? json["Created"], + errorCode: errorCode?.toString(), + errorMsg: errorMsg, + tencentError: json["ErrorMsg"] == null + ? null + : TencentError.fromJson(json["ErrorMsg"]), + + /// 消息主体 + statusCode: json["status_code"], + result: json["result"], + choices: json["Choices"] == null + ? [] + : List.from( + json["Choices"]!.map((x) => CommonChoice.fromJson(x)), + ), + output: + json["output"] == null ? null : AliyunOutput.fromJson(json["output"]), + customReplyText: customReplyText, + + /// 特有 + object: json["object"], + isTruncated: json["is_truncated"], + needClearHistory: json["need_clear_history"], + banRound: json["ban_round"], + sentenceId: json["sentence_id"], + isEnd: json["is_end"], + note: json["Note"], + ); + } + + Map toJson() => { + /// 通用的(百度的大写就额外多一个) + "usage": usage?.toJson(), + "Usage": usage?.toJson(), + "id": id, + "Id": id, + "request_id": requestId, + "RequestId": requestId, + "created": created, + "Created": created, + "error_code": errorCode, + "error_msg": errorMsg, + "code": errorCode, + "message": errorMsg, + "ErrorMsg": tencentError?.toJson(), + + /// 主体内容 + "status_code": statusCode, + // 百度 + "result": result, + // 阿里 + "output": output?.toJson(), + // 腾讯 + "Choices": choices == null + ? [] + : List.from(choices!.map((x) => x.toJson())), + "Note": note, + + /// 一些特有的 + "object": object, + "is_truncated": isTruncated, + "need_clear_history": needClearHistory, + "ban_round": banRound, + "sentence_id": sentenceId, + "is_end": isEnd, + }; +} + +/// 腾讯混元大模型响应的报错体 +TencentError tencentHunYuanErrorFromJson(String str) => + TencentError.fromJson(json.decode(str)); + +String tencentHunYuanErrorToJson(TencentError data) => + json.encode(data.toJson()); + +class TencentError { + String code; + String message; + + TencentError({required this.code, required this.message}); + + factory TencentError.fromJson(Map json) => + TencentError(code: json["Code"], message: json["Message"]); + + Map toJson() => {"Code": code, "Message": message}; +} diff --git a/lib/models/brief_accounting_state.dart b/lib/models/brief_accounting_state.dart new file mode 100644 index 0000000..ce1458f --- /dev/null +++ b/lib/models/brief_accounting_state.dart @@ -0,0 +1,334 @@ +// ignore_for_file: avoid_print + +import 'package:intl/intl.dart'; +import 'package:uuid/uuid.dart'; + +import '../common/constants.dart'; + +/// 2024-05-22 +/// brief_accounting 数据库目前就就1个主要的基础表 +/// 后续可能会根据报表查询sql得到的VO,再说 +/// 如果每笔支出还有非常多的细节,像微信支付宝账单那种,这里只能预留有个支出收入详情表了 + +/// 支出和收入条目 +class BillItem { + String billItemId; // 用uuid生成 + int itemType; // 0 收入;1 支出 + String date; // 日期,yyyy-MM-DD + String? category; // 最简单的分类 + String item; // 条目 + double value; // 数值 + String? gmtModified; // 异动时间 + /// 扩展的几个栏位,懒得加VO了 + String? month; + double? expendTotal; + double? incomeTotal; + + BillItem({ + required this.billItemId, + required this.itemType, + required this.date, + this.category, + required this.item, + required this.value, + this.gmtModified, + this.month, + this.expendTotal, + this.incomeTotal, + }); + + Map toMap() { + return { + 'bill_item_id': billItemId, + 'item_type': itemType, + 'date': date, + 'category': category, + 'item': item, + 'value': value, + 'gmt_modified': gmtModified, + }; + } + + // 主要是账单条目表单初始化值得时候,需要转类型 + Map toStringMap() { + return { + 'bill_item_id': billItemId, + 'item_type': itemType == 0 ? '收入' : '支出', + 'date': DateTime.tryParse(date) ?? DateTime.now(), + 'category': category, + 'item': item, + 'value': value.toStringAsFixed(2), + 'gmt_modified': gmtModified, + }; + } + + factory BillItem.fromMap(Map map) { + return BillItem( + billItemId: map['bill_item_id'] != null + ? map['bill_item_id'].toString() + : const Uuid().v1(), + itemType: map['item_type'] as int, + date: map['date'] as String, + category: map['category'] as String?, + item: map['item'] as String, + value: map['value'] as double, + gmtModified: map['gmt_modified'] as String?, + month: map['month'] as String?, + expendTotal: map['expend_total'] as double?, + incomeTotal: map['income_total'] as double?, + ); + } + + // 从 JSON 映射中创建 User 实例的工厂方法 + factory BillItem.fromJson(Map json) { + return BillItem( + billItemId: json['bill_item_id'] != null + ? json['bill_item_id'].toString() + : const Uuid().v1(), + itemType: json['item_type'] as int, + date: json['date'] as String, + category: json['category'] as String?, + item: json['item'] as String, + value: json['value'] as double, + gmtModified: json['gmt_modified'] ?? + DateFormat(constDatetimeFormat).format(DateTime.now()), + ); + } + + // 将实例转换为 JSON 映射的方法(可选) + Map toJson() { + return { + 'bill_item_id': billItemId, + 'itemType': itemType, + 'date': date, + 'category': category, + 'item': item, + 'value': value, + 'gmt_modified': gmtModified, + }; + } + + @override + String toString() { + return ''' + BillItem{ + billItemId: $billItemId, itemType: $itemType, date: $date, category: $category, + item: $item, value: $value, gmtModified: $gmtModified, + month: $month, expendTotal: $expendTotal, incomeTotal: $incomeTotal, + } + '''; + } +} + +// 一些VO +class BillGroup { + String startDateOfYear; + String startDateOfMonth; + String startDateOfDay; + double totalYear; + double totalMonth; + double totalDay; + + BillGroup({ + this.startDateOfYear = "", + this.startDateOfMonth = "", + this.startDateOfDay = "", + this.totalYear = 0.0, + this.totalMonth = 0.0, + this.totalDay = 0.0, + }); + + void addBillItem(BillItem item) { + String itemDate = item.date; + + // 如果这是新的一天,重置日累计值 + if (itemDate != startDateOfDay) { + startDateOfDay = itemDate; + totalDay = item.value; + } else { + totalDay += item.value; + } + + // 如果这是新的一月,重置月累计值 + if (itemDate.substring(0, 7) != (startDateOfMonth)) { + startDateOfMonth = itemDate.substring(0, 7); + totalMonth = totalDay; // 新的月累计值从当日的累计值开始 + } else { + totalMonth += item.value; + } + + // 如果这是新的一年,重置年累计值 + if (itemDate.substring(0, 4) != startDateOfYear) { + startDateOfYear = itemDate.substring(0, 4); + totalYear = totalMonth; // 新的年累计值从当月的累计值开始 + } else { + totalYear += item.value; + } + } +} + +/// 月度年度统计 +class BillPeriodCount { + String period; + double expendTotalValue; + double incomeTotalValue; + double ratio; + + BillPeriodCount({ + required this.period, + required this.expendTotalValue, + required this.incomeTotalValue, + required this.ratio, + }); + + Map toMap() { + return { + 'period': period, + 'expend_total_value': expendTotalValue, + 'income_total_value': incomeTotalValue, + 'ratio': ratio, + }; + } + + factory BillPeriodCount.fromMap(Map map) { + return BillPeriodCount( + period: map['period'] as String, + expendTotalValue: map['expend_total_value'] as double, + incomeTotalValue: map['income_total_value'] as double, + ratio: map['ratio'] != null ? map['ratio'] as double : 0, + ); + } + + @override + String toString() { + return ''' + BillPeriodCount{period: $period, expendTotalValue: $expendTotalValue, incomeTotalValue: $incomeTotalValue, ratio: $ratio;} + '''; + } +} + +/// 简单的日期范围 +class SimplePeriodRange { + DateTime minDate; + DateTime maxDate; + + SimplePeriodRange({ + required this.minDate, + required this.maxDate, + }); + + Map toMap() { + return { + 'min_date': minDate, + 'max_date': maxDate, + }; + } + + factory SimplePeriodRange.fromMap(Map map) { + return SimplePeriodRange( + minDate: DateTime.tryParse(map['min_date']) ?? DateTime.now(), + maxDate: DateTime.tryParse(map['max_date']) ?? DateTime.now(), + ); + } + + @override + String toString() { + return ''' + SimplePeriodRange{minDate: $minDate, maxDate: $maxDate} + '''; + } +} + +/// ---------------------- 下面的暂时简化为上面,如果后续真的记录非常多,再考虑拆分为支出和收入两部分 + +/// 支出 +class Expend { + String expendId; // 用uuid生成 + String date; // 日期,yyyy-MM-DD + String? category; // 最简单的分类 + String item; // 条目 + double value; // 数值 + + Expend({ + required this.expendId, + required this.date, + this.category, + required this.item, + required this.value, + }); + + Map toMap() { + return { + 'expend_id': expendId, + 'date': date, + 'category': category, + 'item': item, + 'value': value, + }; + } + + factory Expend.fromMap(Map map) { + return Expend( + expendId: map['expend_id'] as String, + date: map['date'] as String, + category: map['category'] as String?, + item: map['item'] as String, + value: map['value'] as double, + ); + } + + @override + String toString() { + return ''' + Expend{ + expendId: $expendId, date: $date, category:$category, item: $item, value: $value, + } + '''; + } +} + +/// 支出 +class Income { + String incomeId; // 用uuid生成 + String date; // 日期,yyyy-MM-DD + String? category; // 最简单的分类 + String item; // 条目 + double value; // 数值 + + Income({ + required this.incomeId, + required this.date, + this.category, + required this.item, + required this.value, + }); + + Map toMap() { + return { + 'income_id': incomeId, + 'date': date, + 'category': category, + 'item': item, + 'value': value, + }; + } + + factory Income.fromMap(Map map) { + return Income( + incomeId: map['income_id'] as String, + date: map['date'] as String, + category: map['category'] as String?, + item: map['item'] as String, + value: map['value'] as double, + ); + } + + @override + String toString() { + return ''' + Expend{ + incomeId: $incomeId, date: $date, category:$category, item: $item, value: $value, + } + '''; + } +} diff --git a/lib/models/common_llm_info.dart b/lib/models/common_llm_info.dart new file mode 100644 index 0000000..5548ec8 --- /dev/null +++ b/lib/models/common_llm_info.dart @@ -0,0 +1,313 @@ +/// 定义云平台 +enum CloudPlatform { + baidu, + tencent, + aliyun, + limited, // 限时限量的测试 +} + +// 模型对应的中文名 +final Map cpNames = { + CloudPlatform.baidu: '百度', + CloudPlatform.tencent: '腾讯', + CloudPlatform.aliyun: '阿里', + CloudPlatform.limited: '限量', +}; + +// 定义一个函数来从字符串获取枚举值 +CloudPlatform stringToCloudPlatform(String value) { + for (final entry in CloudPlatform.values) { + if (entry.name == value) { + return entry; + } + } + // 如果找不到匹配的枚举值,则设置默认值 + return CloudPlatform.aliyun; +} + +/// 所有的平台中可免费使用的模型 +/// 后续可以根据模型的偏向分类,比如有的比较基础,有的比较擅长专业 +/// 有可能的话,用户层不显示这些模型信息,平台连接的接口都统一话,服务于某方面的功能 +enum PlatformLLM { + // 少量免费的(9个) + baiduErnieSpeed8KFREE, + baiduErnieSpeed128KFREE, + // baiduErnieSpeedAppBuilder, + baiduErnieLite8KFREE, // 2024-06-12 有短信通知,使用不带日期后缀的为宜 + baiduErnieTiny8KFREE, + tencentHunyuanLiteFREE, + aliyunQwen1p8BChatFREE, // 1.8b -> 1point8b -> 1p8b + aliyunQwen1p51p8BChatFREE, // 千问1.5开源本,18亿参数 + aliyunQwen1p50p5BChatFREE, // 千问1.5开源本,5亿参数 + aliyunFaruiPlus32KFREE, + + /// 其实就是阿里云中限时限量的部分(25个) + limitedQwenMax, // 8k,6k输入 + limitedQwenMax0428, // 8k,6k输入 + limitedQwenLong, // 10000k + limitedQwenMaxLongContext, // 28k + limitedQwenPlus, // 32k + limitedQwenTurbo, // 8k + limitedQwenVLMax, // 支持上传图片理解的对话 + limitedQwenVLPlus, // 支持上传图片理解的对话 + + limitedBaichuan2Turbo, // 8k + limitedBaichuan2Turbo192K, // 192k + + limitedBaichuan27BChatV1, // 4k + limitedBaichuan213BChatV1, // 4k + limitedBaichuan7BV1, // 4k + + limitedMoonshotV18K, // 8k + limitedMoonshotV132K, // 32k + limitedMoonshotV1128K, // 128k + + limitedLLaMa38B, // 8k + limitedLLaMa370B, // 128k + limitedLLaMa213B, // 128k + + limitedChatGLM26B, // 8k + limitedChatGLM36B, // 8k + + limitedYiLarge, // 32k + limitedYiLargeTurbo, // 16K + limitedYiLargeRAG, // 16K + limitedYiMedium, // 16K + + // 少量支持用户自行配置的付费的(10个) + baiduErnie4p08K, + baiduErnie3p58K, + + aliyunQwenMax, + aliyunQwenPlus, + aliyunQwenTurbo, + aliyunQwenLong, + aliyunQwenMaxLongContext, + + tencentHunyuanPro, + tencentHunyuanStandard, + tencentHunyuanStandard256k, +} + +// 限时限量的对话模型(比底部的示例那个简单点) +class ChatLLMSpec { + // 模型字符串(平台API参数的那个model的值)、模型名称、上下文长度数值,到期时间、限量数值, + /// 收费输入时百万token价格价格,输出时百万token价格(限时免费没写价格就先写0) + final String model; + final String name; + final int contextLength; + final DateTime deadline; + final int freeAmount; + final double inputPrice; // 每千token单价 + final double outputPrice; + // 2024-06-21 + // 是否是视觉理解大模型(即是否可以解析图片、分析图片内容,然后进行对话) + // 比如通义千问-VL 的接口参数和对话的基本无二致,只是入参多个图像image,所以可以放到一起试试 + // 如果模型支持视觉,就显示上传图片按钮,可加载图片 + bool? isVisonLLM; + // 模型简述 + String? spec; + + ChatLLMSpec(this.model, this.name, this.contextLength, this.deadline, + this.freeAmount, this.inputPrice, this.outputPrice, + {this.isVisonLLM = false, this.spec}); +} + +// 2024-06-15 阿里云的限时限量都是这两个值,放在外面好了 +final dt1 = DateTime.parse("2024-07-04"); +const num1 = 400 * 10000; +final dt2 = DateTime.parse("2024-12-02"); +const num2 = 100 * 10000; + +// 2024-06-20 官方免费的,其实不限时限量 +final dt3 = DateTime.parse("2099-12-31"); +const num3 = -1 >>> 1; + +/// 2024-06-14 这个可以抽成1个对象即可,暂时先改限时限量版本的 +/// list 取值一定记住顺序:模型字符串(平台API参数的那个model的值)、模型名称、上下文长度数值,限时字符串、限量数值, +/// 收费输入时百万token价格价格,输出时百万token价格(限时免费没写价格就先写0) +/// 2024-06-15 后续应该放到配置文件,或者用户导入(自行输入,那就要配置平台、密钥等,这就比较麻烦点了) +final Map newLLMSpecs = { + /// 下面是官方免费的 + PlatformLLM.baiduErnieSpeed8KFREE: ChatLLMSpec( + "ernie_speed", 'ERNIESpeed8K', 8 * 1000, dt3, num3, 0.0, 0.0, + spec: + "ERNIE Speed是百度2024年最新发布的自研高性能大语言模型,通用能力优异,适合作为基座模型进行精调,更好地处理特定场景问题,同时具备极佳的推理性能。\n\nERNIE-Speed-8K是模型的一个版本,上下文窗口为8K。"), + PlatformLLM.baiduErnieSpeed128KFREE: ChatLLMSpec( + "ernie-speed-128k", 'ERNIESpeed128K', 128 * 1000, dt3, num3, 0.0, 0.0, + spec: + 'ERNIE Speed是百度2024年最新发布的自研高性能大语言模型,通用能力优异,适合作为基座模型进行精调,更好地处理特定场景问题,同时具备极佳的推理性能。\n\nERNIE-Speed-128K是模型的一个版本,上下文窗口为128K。。'), + PlatformLLM.baiduErnieLite8KFREE: ChatLLMSpec( + "ernie-lite-8k", 'ERNIELite8K', 8 * 1000, dt3, num3, 0.0, 0.0, + spec: "ERNIE Lite是百度自研的轻量级大语言模型,兼顾优异的模型效果与推理性能,适合低算力AI加速卡推理使用。"), + PlatformLLM.baiduErnieTiny8KFREE: ChatLLMSpec( + "ernie-tiny-8k", 'ERNIETiny8K', 8 * 1000, dt3, num3, 0.0, 0.0, + spec: + "ERNIE Tiny是百度自研的超高性能大语言模型,部署与精调成本在文心系列模型中最低。\n\nERNIE-Tiny-8K是模型的一个版本,上下文窗口为8K。"), + PlatformLLM.tencentHunyuanLiteFREE: ChatLLMSpec( + "hunyuan-lite", '混元Lite', 8 * 1000, dt3, num3, 0.0, 0.0, + spec: + "腾讯混元大模型(Tencent Hunyuan)是由腾讯研发的大语言模型,具备强大的中文创作能力,复杂语境下的逻辑推理能力,以及可靠的任务执行能力。\\nn混元-Lite 升级为MOE结构,上下文窗口为256k,在NLP,代码,数学,行业等多项评测集上领先众多开源模型。"), + PlatformLLM.aliyunQwen1p8BChatFREE: ChatLLMSpec( + "qwen-1.8b-chat", '通义千问开源版1.8B', 8 * 1000, dt3, num3, 0.0, 0.0, + spec: + '"通义千问-开源版-1.8B"是通义千问对外开源的1.8B规模参数量的经过人类指令对齐的chat模型,模型支持 8k tokens上下文,API限定用户输入为6k Tokens。'), + PlatformLLM.aliyunQwen1p51p8BChatFREE: ChatLLMSpec( + "qwen1.5-1.8b-chat", '通义千问1.5开源版', 8 * 1000, dt3, num3, 0.0, 0.0, + spec: + '通义千问1.5-开源版-1.8B"是通义千问1.5对外开源的1.8B规模参数量是经过人类指令对齐的chat模型,模型支持 32k tokens上下文,API限定用户输入为30k Tokens。'), + PlatformLLM.aliyunQwen1p50p5BChatFREE: ChatLLMSpec( + "qwen1.5-0.5b-chat", '通义千问1.5开源版0.5B', 8 * 1000, dt3, num3, 0.0, 0.0, + spec: + '"通义千问1.5-开源版-0.5B"是通义千问1.5对外开源的0.5B规模参数量是经过人类指令对齐的chat模型,模型支持 32k tokens上下文,API限定用户输入为30k Tokens。'), + PlatformLLM.aliyunFaruiPlus32KFREE: ChatLLMSpec( + "farui-plus", '通义法睿Plus32K', 8 * 1000, dt3, num3, 0.0, 0.0, + spec: + '"通义法睿"是以通义千问为基座经法律行业数据和知识专门训练的法律行业大模型产品,综合运用了模型精调、强化学习、 RAG检索增强、法律Agent技术,具有回答法律问题、推理法律适用、推荐裁判类案、辅助案情分析、生成法律文书、检索法律知识、审查合同条款等功能。'), + + /// 下面是支持用户自行配置的少数几个(用户自己配置的,也当作不限时限量) + + // 百度 + PlatformLLM.baiduErnie4p08K: ChatLLMSpec( + "completions_pro", 'ERNIE-4.0-8K', 8 * 1000, dt3, num3, 0.0, 0.0), + PlatformLLM.baiduErnie3p58K: + ChatLLMSpec("completions", 'ERNIE-3.5-8K', 8 * 1000, dt3, num3, 0.0, 0.0), + // 阿里 + PlatformLLM.aliyunQwenMax: + ChatLLMSpec("qwen-max", '通义千问-Max', 8 * 1000, dt3, num3, 0.0, 0.0), + PlatformLLM.aliyunQwenPlus: + ChatLLMSpec('qwen-plus', '通义千问-Plus', 32 * 1000, dt3, num3, 0.0, 0.0), + PlatformLLM.aliyunQwenTurbo: + ChatLLMSpec('qwen-turbo', '通义千问-Turbo', 8 * 1000, dt3, num3, 0.0, 0.0), + PlatformLLM.aliyunQwenLong: + ChatLLMSpec("qwen-long", '通义千问-长文', 10000 * 1000, dt3, num3, 0.0, 0.0), + PlatformLLM.aliyunQwenMaxLongContext: ChatLLMSpec( + 'qwen-max-longcontext', '通义千问-Max-30K', 28 * 1000, dt3, num3, 0.0, 0.0), + // 腾讯 + PlatformLLM.tencentHunyuanPro: + ChatLLMSpec('hunyuan-pro', '混元Pro', 8 * 1000, dt3, num3, 0.0, 0.0), + PlatformLLM.tencentHunyuanStandard: ChatLLMSpec( + 'hunyuan-standard', '混元Standard', 8 * 1000, dt3, num3, 0.0, 0.0), + PlatformLLM.tencentHunyuanStandard256k: ChatLLMSpec( + 'hunyuan-standard-256K', '混元Standard256K', 8 * 1000, dt3, num3, 0.0, 0.0), + + /// 下面是受限的(因为使用一个虚构的limited平台,所以在显示的名称后面手动加上平台) + // 通义千问 + PlatformLLM.limitedQwenMax: + ChatLLMSpec("qwen-max", '通义千问-Max_阿里云', 8 * 1000, dt1, num1, 0.04, 0.12), + PlatformLLM.limitedQwenMax0428: ChatLLMSpec( + "qwen-max-0428", '通义千问-Max-0428_阿里云', 8 * 1000, dt1, num2, 0.04, 0.12), + PlatformLLM.limitedQwenPlus: ChatLLMSpec( + 'qwen-plus', '通义千问-Plus_阿里云', 32 * 1000, dt1, num1, 0.004, 0.012), + PlatformLLM.limitedQwenTurbo: ChatLLMSpec( + 'qwen-turbo', '通义千问-Turbo_阿里云', 8 * 1000, dt1, num1, 0.002, 0.006), + PlatformLLM.limitedQwenLong: ChatLLMSpec( + "qwen-long", '通义千问-长文_阿里云', 10000 * 1000, dt1, num1, 0.04, 0.12), + PlatformLLM.limitedQwenMaxLongContext: ChatLLMSpec('qwen-max-longcontext', + '通义千问-Max-30K_阿里云', 28 * 1000, dt1, num2, 0.04, 0.12), + // 2024-06-21 视觉理解大模型 + PlatformLLM.limitedQwenVLMax: ChatLLMSpec( + 'qwen-vl-max', '通义千问VL-Max_阿里云', 8 * 1000, dt1, num2, 0.02, 0.02, + isVisonLLM: true), + PlatformLLM.limitedQwenVLPlus: ChatLLMSpec( + 'qwen-vl-plus', '通义千问VL-Plus_阿里云', 8 * 1000, dt1, num2, 0.008, 0.008, + isVisonLLM: true), + + // 百川 + PlatformLLM.limitedBaichuan2Turbo: ChatLLMSpec('baichuan2-turbo', + 'Baichuan2-Turbo_百川', 4 * 1000, dt2, num2, 0.008, 0.008), + PlatformLLM.limitedBaichuan2Turbo192K: ChatLLMSpec('baichuan2-turbo-192k', + 'Baichuan2-Turbo-192k_百川', 192 * 1000, dt2, num2, 0.008, 0.008), + + // 百川开源版 + PlatformLLM.limitedBaichuan27BChatV1: ChatLLMSpec('baichuan2-7b-chat-v1', + 'Baichuan2-7B_百川', 4 * 1000, dt2, num2, 0.008, 0.008), + PlatformLLM.limitedBaichuan213BChatV1: ChatLLMSpec('baichuan2-13b-chat-v1', + 'Baichuan2-开源版-13B_百川', 4 * 1000, dt2, num2, 0.008, 0.008), + PlatformLLM.limitedBaichuan7BV1: ChatLLMSpec('baichuan-7b-v1', + 'Baichuan2-开源版-7B_百川', 4 * 1000, dt2, num2, 0.008, 0.008), + + // 月之暗面 + PlatformLLM.limitedMoonshotV18K: ChatLLMSpec('moonshot-v1-8k', + 'Moonshot-v1-8K_月之暗面', 8 * 1000, dt2, num2, 0.008, 0.008), + PlatformLLM.limitedMoonshotV132K: ChatLLMSpec('moonshot-v1-32k', + 'Moonshot-v1-32K_月之暗面', 32 * 1000, dt2, num2, 0.008, 0.008), + PlatformLLM.limitedMoonshotV1128K: ChatLLMSpec('moonshot-v1-128k', + 'Moonshot-v1-128K_月之暗面', 128 * 1000, dt2, num2, 0.008, 0.008), + + // LLaMa + PlatformLLM.limitedLLaMa38B: ChatLLMSpec( + 'llama3-8b-instruct', 'LLaMa3-8B_Meta', 8 * 1000, dt2, num2, 0.1, 0.1), + PlatformLLM.limitedLLaMa370B: ChatLLMSpec( + 'llama3-70b-instruct', 'LLaMa3-70B_Meta', 8 * 1000, dt2, num2, 0.1, 0.1), + PlatformLLM.limitedLLaMa213B: ChatLLMSpec( + 'llama2-13b-chat-v2', 'Llama2-13B_Meta', 8 * 1000, dt2, num2, 0.1, 0.1), + + // 智谱 + PlatformLLM.limitedChatGLM26B: ChatLLMSpec( + 'chatglm-6b-v2', 'ChatGLM2-6B_智谱', 8 * 1000, dt2, num2, 0.006, 0.006), + PlatformLLM.limitedChatGLM36B: ChatLLMSpec( + 'chatglm3-6b', 'ChatGLM3-开源版-6B_智谱', 8 * 1000, dt2, num2, 0.006, 0.006), + + // 零一万物 + PlatformLLM.limitedYiLarge: + ChatLLMSpec('yi-large', 'Yi-Large_零一万物', 32000, dt2, num2, 0, 0), + PlatformLLM.limitedYiLargeTurbo: ChatLLMSpec( + 'yi-large-turbo', 'Yi-Large-Turbo_零一万物', 16000, dt2, num2, 0, 0), + PlatformLLM.limitedYiLargeRAG: + ChatLLMSpec('yi-large-rag', 'Yi-Large-RAG_零一万物', 16000, dt2, num2, 1, 1), + PlatformLLM.limitedYiMedium: + ChatLLMSpec('yi-medium', 'Yi-Medium_零一万物', 16000, dt2, num2, 1, 1), +}; + +/// +/// +/// 2024-06-14 大模型简单分成积累,默认的是对话模型,文生图、图生文等得另外来 +/// +/// +enum Image2TextLLM { + baiduFuyu8B, // 百度平台第三方的图像理解模型 +} + +final Map i2tLlmModels = { + Image2TextLLM.baiduFuyu8B: 'fuyu-8b', +}; +final Map i2tLlmNames = { + Image2TextLLM.baiduFuyu8B: 'Fuyu-8B', +}; +final Map i2tLlmDescriptions = { + Image2TextLLM.baiduFuyu8B: + 'Fuyu-8B是由Adept AI训练的多模态图像理解模型,可以支持多样的图像分辨率,回答图形图表有关问题。模型在视觉问答和图像描述等任务上表现良好。', +}; + +/// +/// +/// 2024-06-21 上面的是单纯文本对话的大模型,下面是视觉理解大模型,即可以上传图片 +/// 没法合并到一起是因为,之前通用的请求参数类中的 CommonMessage 的content 属性: +/// 前者是一个String即可, +/// 后者需要 [{String text,String image}] 的 list, +/// 也为了区分,切换不同页面,单独一个规格 +/// +/// + +// 通用视觉理解大模型信息 +class VsionLLMSpec { + // 模型字符串(平台API参数的那个model的值)、模型名称、上下文长度数值,到期时间、限量数值, + /// 收费输入时百万token价格价格,输出时百万token价格(限时免费没写价格就先写0) + final String model; + final String name; + final int contextLength; + final DateTime deadline; + final int freeAmount; + final double inputPrice; // 每千token单价 + final double outputPrice; + // 2024-06-21 + // 是否是视觉理解大模型(即是否可以解析图片、分析图片内容,然后进行对话) + // 比如通义千问-VL 的接口参数和对话的基本无二致,只是入参多个图像image,所以可以放到一起试试 + // 如果模型支持视觉,就显示上传图片按钮,可加载图片 + bool? isVisonLLM; + + VsionLLMSpec(this.model, this.name, this.contextLength, this.deadline, + this.freeAmount, this.inputPrice, this.outputPrice, + {this.isVisonLLM = false}); +} diff --git a/lib/models/dish.dart b/lib/models/dish.dart new file mode 100644 index 0000000..2b8a4c2 --- /dev/null +++ b/lib/models/dish.dart @@ -0,0 +1,123 @@ +// ignore_for_file: avoid_print + +// 食物 +class Dish { + String dishId; // 用uuid生成 + String dishName; // 名称 + String? description; // 一两句的介绍描述 + String? recipe; // 菜谱 + // 2024-03-22 输入菜谱可能太慢了,可以拍照片,但固定只能一张照片 + String? recipePicture; // 菜谱照片地址 + // 照片、类型、餐次一个食物可以对应多个 + String? photos; // 食物照片。实际照片缓存内部或者网页照片,这里是地址列表 + String? videos; // 食物视频。这里是地址,外部访问 + String? tags; // 食物类型,比如凉菜、汤菜、煎、炒、烹、炸、焖、溜、熬、炖、汆等 + String? mealCategories; // 早餐、午餐、下午茶、晚餐、夜宵、甜点 + + Dish({ + required this.dishId, + required this.dishName, + this.description, + this.photos, + this.videos, + this.tags, + this.mealCategories, + this.recipe, + this.recipePicture, + }); + + Map toMap() { + return { + 'dish_id': dishId, + 'dish_name': dishName, + 'description': description, + 'photos': photos, + 'videos': videos, + 'tags': tags, + 'meal_categories': mealCategories, + 'recipe': recipe, + 'recipe_picture': recipePicture, + }; + } + +// 用于从数据库行映射到 ServingInfo 对象的 fromMap 方法 + factory Dish.fromMap(Map map) { + return Dish( + dishId: map['dish_id'] as String, + dishName: map['dish_name'] as String, + description: map['description'] as String?, + photos: map['photos'] as String?, + videos: map['videos'] as String?, + tags: map['tags'] as String?, + mealCategories: map['meal_categories'] as String?, + recipe: map['recipe'] as String?, + recipePicture: map['recipe_picture'] as String?, + ); + } + + @override + String toString() { + return ''' + Food{ + dishId: $dishId, dishName: $dishName, description:$description, + photos: $photos, videos: $videos, recipePicture: $recipePicture, + tags: $tags, mealCategories: $mealCategories, recipe: $recipe, + } + '''; + } +} + +/// json 文件转换时对应的类 + +class JsonFileDish { + String? dishId; // 用uuid生成 (就是上面的food,后面再改) + String? dishName; // 名称 + String? description; // 一两句的介绍描述 + // 照片、类型、餐次一个食物可以对应多个 + String? tags; // 食物类型,比如凉菜、汤菜、煎、炒、烹、炸、焖、溜、熬、炖、汆等 + String? mealCategories; // 早餐、午餐、下午茶、晚餐、夜宵、甜点 + List? recipe; // 菜谱,用字符串数组装步骤了 + // 2024-03-22 输入菜谱可能太慢了,可以拍照片,但固定只能一张照片 + String? recipePicture; // 菜谱照片地址 + List? images; // 食物照片。照片地址也用字符串数组 + List? videos; // 食物视频。视频地址也用字符串数组 + + JsonFileDish({ + this.dishId, + this.dishName, + this.description, + this.tags, + this.mealCategories, + this.recipe, + this.recipePicture, + this.images, + this.videos, + }); + + JsonFileDish.fromJson(Map json) { + dishId = json['dish_id']; + dishName = json['dish_name'] ?? ""; + description = json['description']; + tags = json['tags']; + mealCategories = json['meal_categories']; + recipe = json['recipe']?.cast(); + recipePicture = json['recipe_picture']; + images = json['images']?.cast(); + videos = json['videos']?.cast(); + } + + Map toJson() { + final Map data = {}; + data['dish_id'] = dishId; + data['dish_name'] = dishName; + data['description'] = description; + data['tags'] = tags; + data['meal_categories'] = mealCategories; + data['recipe'] = recipe; + data['recipe_picture'] = recipePicture; + data['images'] = images; + data['videos'] = videos; + + return data; + } +} diff --git a/lib/models/llm_chat_state.dart b/lib/models/llm_chat_state.dart new file mode 100644 index 0000000..066d8d4 --- /dev/null +++ b/lib/models/llm_chat_state.dart @@ -0,0 +1,211 @@ +import 'dart:convert'; + +import 'package:intl/intl.dart'; + +import '../common/constants.dart'; + +/// 人机对话的每一条消息的结果 +/// 对话页面就是包含一系列时间顺序排序后的对话消息的list +class ChatMessage { + final String messageId; // 每个消息有个ID方便整个对话列表的保存??? + final String text; // 文本内容 + DateTime dateTime; // 时间 + final bool isFromUser; // 是否来自用户 + final String? avatarUrl; // 头像URL + final bool? isPlaceholder; // 是否是等待响应时的占位消息 + /// 2024-06-15 限时限量有token限制,所以存放每次对话的token消耗 + final int? inputTokens; + final int? outputTokens; + final int? totalTokens; + + ChatMessage({ + required this.messageId, + required this.text, + required this.dateTime, + required this.isFromUser, + this.avatarUrl, + this.isPlaceholder, + this.inputTokens, + this.outputTokens, + this.totalTokens, + }); + + Map toMap() { + return { + 'message_id': messageId, + 'text': text, + 'date_time': dateTime, + 'is_from_user': isFromUser, + 'avatar_url': avatarUrl, + 'is_placeholder': isPlaceholder, + 'input_tokens': inputTokens, + 'output_tokens': outputTokens, + 'total_tokens': totalTokens, + }; + } + +// fromMap 一般是数据库读取时用到 +// fromJson 一般是从接口或者其他文本转换时用到 +// 2024-06-03 使用parse而不是tryParse就可能会因为格式不对抛出异常 +// 但是存入数据不对就是逻辑实现哪里出了问题。使用后者默认值也不知道该使用哪个。 + factory ChatMessage.fromMap(Map map) { + return ChatMessage( + messageId: map['message_id'] as String, + text: map['text'] as String, + dateTime: DateTime.parse(map['date_time']), + isFromUser: bool.parse(map['is_from_user']), + avatarUrl: map['avatar_url'] as String?, + isPlaceholder: bool.tryParse(map['is_placeholder']), + inputTokens: int.tryParse(map['input_tokens']), + outputTokens: int.tryParse(map['output_tokens']), + totalTokens: int.tryParse(map['total_tokens']), + ); + } + + factory ChatMessage.fromJson(Map json) => ChatMessage( + messageId: json["message_id"], + text: json["text"], + dateTime: DateTime.parse(json["date_time"]), + isFromUser: bool.parse(json["is_from_user"]), + avatarUrl: json["avatar_url"], + isPlaceholder: bool.tryParse(json["is_placeholder"]), + inputTokens: int.tryParse(json["input_tokens"]), + outputTokens: int.tryParse(json["output_tokens"]), + totalTokens: int.tryParse(json["total_tokens"]), + ); + + Map toJson() => { + "message_id": messageId, + "text": text, + "date_time": dateTime, + "is_from_user": isFromUser, + "avatar_url": avatarUrl, + "is_placeholder": isPlaceholder, + "input_tokens": inputTokens, + "output_tokens": outputTokens, + "total_tokens": totalTokens, + }; + + @override + String toString() { + // 2024-06-03 这个对话会被作为string存入数据库,然后再被读取转型为ChatMessage。 + // 所以需要是个完整的json字符串,一般fromMap时可以处理 + return ''' + { + "message_id": "$messageId", + "text": ${jsonEncode(text)}, + "date_time": "$dateTime", + "is_from_user": "$isFromUser", + "avatar_url": "$avatarUrl", + "is_placeholder":"$isPlaceholder", + "input_tokens":"$inputTokens", + "output_tokens":"$outputTokens", + "total_tokens":"$totalTokens" + } + '''; + } +} + +/// 对话记录 这个是存入sqlite的表对应的模型 +// 一次对话记录需要一个标题,首次创建的时间,然后包含很多的对话消息 +class ChatSession { + final String uuid; + // 因为该栏位需要可修改,就不能为final了 + String title; + final DateTime gmtCreate; + // 因为该栏位需要可修改,就不能为final了 + List messages; + // 2024-06-01 大模型名称也要记一下,说不定后续要存API的原始返回内容复用 + // 2024-06-20 这里记录的是自定义的模型名(类似 PlatformLLM.baiduErnieSpeed8KFREE) + // 因为后续查询历史记录可能会用此栏位来过滤 + final String llmName; // 使用的大模型名称需要记一下吗? + // 2024-06-06 记录了大模型名称,也记一下使用在哪个云平台 + final String? cloudPlatformName; + + /// 图像理解也是对话记录,所以增加一个类别 + String chatType; // aigc\image2text\text2image + // ???2024-06-14 在图像理解中可以复用对话,存放被理解的图片的base64字符串 + // base64在memoryImage中可能会因为重复渲染而一闪一闪,还是存图片地址好了 + // i2t => image to text + String? i2tImagePath; + + ChatSession({ + required this.uuid, + required this.title, + required this.gmtCreate, + required this.messages, + required this.llmName, + this.cloudPlatformName, + this.i2tImagePath, + required this.chatType, + }); + + factory ChatSession.fromMap(Map map) { + return ChatSession( + uuid: map['uuid'] as String, + title: map['title'] as String, + gmtCreate: DateTime.tryParse(map['gmt_create']) ?? DateTime.now(), + messages: (jsonDecode(map['messages'] as String) as List) + .map((messageMap) => + ChatMessage.fromMap(messageMap as Map)) + .toList(), + llmName: map['llm_name'] as String, + cloudPlatformName: map['yun_platform_name'] as String?, + i2tImagePath: map['i2t_image_path'] as String?, + chatType: map['chat_type'] as String, + ); + } + + Map toMap() { + return { + 'uuid': uuid, + 'title': title, + 'gmt_create': DateFormat(constDatetimeFormat).format(gmtCreate), + 'messages': messages.toString(), + 'llm_name': llmName, + 'yun_platform_name': cloudPlatformName, + 'chat_type': chatType, + 'i2t_image_path': i2tImagePath, + }; + } + + factory ChatSession.fromJson(Map json) => ChatSession( + uuid: json["uuid"], + messages: List.from( + json["messages"].map((x) => ChatMessage.fromJson(x)), + ), + title: json["title"], + gmtCreate: json["gmt_create"], + llmName: json["llm_name"], + cloudPlatformName: json["yun_platform_name"], + chatType: json["chat_type"], + i2tImagePath: json["i2t_image_path"], + ); + + Map toJson() => { + "uuid": uuid, + "messages": List.from(messages.map((x) => x.toJson())), + "title": title, + "gmt_create": gmtCreate, + "llm_name": llmName, + "yun_platform_name": cloudPlatformName, + "i2t_image_path": i2tImagePath, + 'chat_type': chatType, + }; + + @override + String toString() { + return ''' + ChatSession { + "uuid": $uuid, + "title": $title, + "gmtCreate": $gmtCreate, + "llmName": $llmName, + "cloudPlatformName": $cloudPlatformName, + 'chatType': $chatType, + "i2tImageBase64": ${(i2tImagePath != null && i2tImagePath!.length > 10) ? i2tImagePath?.substring(0, 10) : i2tImagePath}, + "messages": $messages + } + '''; + } +} diff --git a/lib/models/llm_text2image_state.dart b/lib/models/llm_text2image_state.dart new file mode 100644 index 0000000..66f42bf --- /dev/null +++ b/lib/models/llm_text2image_state.dart @@ -0,0 +1,48 @@ +import 'package:intl/intl.dart'; + +import '../common/constants.dart'; + +/// +/// 2024-06-13 +/// 大模型文生图,也把url存到数据库中,超时没法下载那也是用户的问题 +/// + +class TextToImageResult { + final String requestId; // 每个消息有个ID方便整个对话列表的保存??? + final String prompt; // 正向提示词 + String? negativePrompt; // 消极提示词 + final String style; // 图片风格 + List? imageUrls; // 图片地址,数据库存分号连接的字符串(一般都在平台的oss中,有超时设定) + DateTime gmtCreate; // 创建时间 + + TextToImageResult({ + required this.requestId, + required this.prompt, + this.negativePrompt, + required this.style, + this.imageUrls, + required this.gmtCreate, + }); + + factory TextToImageResult.fromMap(Map map) { + return TextToImageResult( + requestId: map['request_id'] as String, + prompt: map['prompt'] as String, + negativePrompt: map['negative_prompt'] as String?, + style: map['style'] as String, + imageUrls: (map['image_urls'] as String?)?.split(";").toList(), + gmtCreate: DateTime.tryParse(map['gmt_create']) ?? DateTime.now(), + ); + } + + Map toMap() { + return { + 'request_id': requestId, + 'prompt': prompt, + 'negative_prompt': negativePrompt, + 'style': style, + 'image_urls': imageUrls?.join(";"), // 存入数据库用分号分割,取的时候也一样 + 'gmt_create': DateFormat(constDatetimeFormat).format(gmtCreate), + }; + } +} diff --git a/lib/services/cus_get_storage.dart b/lib/services/cus_get_storage.dart new file mode 100644 index 0000000..cb1085a --- /dev/null +++ b/lib/services/cus_get_storage.dart @@ -0,0 +1,74 @@ +import 'package:get_storage/get_storage.dart'; + +final box = GetStorage(); + +class MyGetStorage { + /// 将使用者自己输入的平台、模型名、应用ID和Key存入缓存 + Future setCusPlatform(String platform) async { + await box.write("cus_platform", platform); + } + + String? getCusPlatform() => box.read("cus_platform"); + + Future setCusLlmName(String llmName) async { + await box.write("cus_llm_name", llmName); + } + + String? getCusLlmName() => box.read("cus_llm_name"); + + /// 2024-06-22平台应用通用配置,就是配这平台一个应用,不指定模型都可以用 + /// set id或key可以指定null,用于清空(是否配置的判断也是用非空判断的) + /// 阿里云的id和key + Future setAliyunCommonAppId(String? appId) async { + await box.write("cus_aliyun_app_id", appId); + } + + String? getAliyunCommonAppId() => box.read("cus_aliyun_app_id"); + + Future setAliyunCommonAppKey(String? appKey) async { + await box.write("cus_aliyun_app_key", appKey); + } + + String? getAliyunCommonAppKey() => box.read("cus_aliyun_app_key"); + + /// 百度的id和key + Future setBaiduCommonAppId(String? appId) async { + await box.write("cus_baidu_app_id", appId); + } + + String? getBaiduCommonAppId() => box.read("cus_baidu_app_id"); + + Future setBaiduCommonAppKey(String? appKey) async { + await box.write("cus_baidu_app_key", appKey); + } + + String? getBaiduCommonAppKey() => box.read("cus_baidu_app_key"); + + /// 腾讯的id和key + Future setTencentCommonAppId(String? appId) async { + await box.write("cus_tencent_app_id", appId); + } + + String? getTencentCommonAppId() => box.read("cus_tencent_app_id"); + + Future setTencentCommonAppKey(String? appKey) async { + await box.write("cus_tencent_app_key", appKey); + } + + String? getTencentCommonAppKey() => box.read("cus_tencent_app_key"); + + // 2024-06-24 是否使用开发者的平台应用 + Future setIsAuthorsAppInfo(bool? flag) async { + await box.write("is_authors_app_info", flag); + } + + bool getIsAuthorsAppInfo() => + bool.tryParse(box.read("is_authors_app_info").toString()) ?? false; + + // 2024-06-27 用户头像地址 + Future setUserAvatarPath(String? flag) async { + await box.write("user_avatar_path", flag); + } + + String? getUserAvatarPath() => box.read("user_avatar_path"); +} diff --git a/lib/views/accounting/bill_item_modify/index.dart b/lib/views/accounting/bill_item_modify/index.dart new file mode 100644 index 0000000..83b39d1 --- /dev/null +++ b/lib/views/accounting/bill_item_modify/index.dart @@ -0,0 +1,361 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:intl/intl.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../common/components/tool_widget.dart'; +import '../../../common/constants.dart'; +import '../../../common/db_tools/db_helper.dart'; +import '../../../models/brief_accounting_state.dart'; + +/// +/// 新增账单条目的简单布局: +/// +/// 收入/支出选项(switch之类也行) +/// 选择大分类 category +/// 输入细项 item +/// 输入金额 value +/// 指定日期 datetime picker,默认当前,但也可以是添加以前的流水项目 +/// +class BillEditPage extends StatefulWidget { + // 列表页面长按修改的时候可能会传账单条目 + final BillItem? billItem; + + const BillEditPage({super.key, this.billItem}); + + @override + State createState() => _BillEditPageState(); +} + +class _BillEditPageState extends State { + final DBHelper _dbHelper = DBHelper(); + + // 表单的全局key + final GlobalKey _formKey = GlobalKey(); + + // 表单输入金额是否有错 + bool _amountHasError = false; + + // 保存中 + bool isLoading = false; + + void _onChanged(dynamic val) => debugPrint(val.toString()); + + // 这些选项都是FormBuilderChipOption类型 + String selectedCategoryType = "支出"; + var categoryList = [ + // 2024-06-03 现在使用大分类(会修改测试数据,即旧的资料,别再动力) + "餐饮", "交通", "购物", "服饰", "水果", "住宿", + "娱乐", "缴费", "数码", "运动", "旅行", "宠物", + "教育", "医疗", "红包", "转账", "人情", "轻奢", + "美容", "亲子", "保险", "公益", "服务", "其他", + // 之前旧的分类,留存 + // // 饮食 + // "三餐", "外卖", "零食", "夜宵", "烟酒", "饮料", + // // 购物 + // "购物", "买菜", "日用", "水果", "买花", "服装", + // // 娱乐 + // "娱乐", "电影", "旅行", "运动", "纪念", "充值", + // // 住、行 + // "交通", "住房", "房租", "房贷", + // // 生活 + // "理发", "还款", + ]; + var incomeCategoryList = [ + // 收入 + "工资", "奖金", "生意", "摆摊", "红包", "转账", + "投资", "炒股", "基金", "人情", "退款", "其他", + ]; + + // 微信账单中的分类 + var outCates = [ + // 第一行 + "餐饮", "交通", "服饰", "购物", "服务", "教育", + "娱乐", "运动", "生活缴费", "旅行", "宠物", "医疗", + "保险", "公益", "发红包", "转账", "亲属卡", "其他人情", + "其他", "服饰美容", "酒店", "亲子", "退还" + ]; + var inCates = [ + // 第一行 + "生意", "工资", "奖金", "其他人情", "收红包", "收转账", + "商家转账", "退款", "其他", + ]; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + // 如果有传表单的初始对象值,就显示该值 + if (widget.billItem != null) { + setState(() { + _formKey.currentState?.patchValue(widget.billItem!.toStringMap()); + selectedCategoryType = widget.billItem!.itemType != 0 ? "支出" : "收入"; + }); + } + }); + } + + // ???初始化之后就不能改了,没法按照收入支出分类切换后更新分类栏位初始化值 + initType() { + return selectedCategoryType == "收入" ? "工资" : "餐饮"; + } + + // 构建收支条目 + List> _categoryChipOptions() { + return (selectedCategoryType == "支出" ? categoryList : incomeCategoryList) + .map((e) => FormBuilderChipOption(value: e)) + .toList(); + } + + /// 保存账单条目到数据库 + saveBillItem() async { + if (_formKey.currentState!.saveAndValidate()) { + if (isLoading) return; + setState(() { + isLoading = true; + }); + + var temp = _formKey.currentState!.value; + + var tempItem = BillItem( + billItemId: const Uuid().v4(), + itemType: temp['item_type'] == '收入' ? 0 : 1, + date: DateFormat(constDateFormat).format(temp['date']), + category: temp['category'], + item: temp['item'], + value: double.tryParse(temp['value']) ?? 0, + gmtModified: DateFormat(constDatetimeFormat).format(DateTime.now()), + ); + + try { + // 没传是新增 + if (widget.billItem == null) { + await _dbHelper.insertBillItemList([tempItem]); + } else { + // 有传是修改 + tempItem.billItemId = widget.billItem!.billItemId; + await _dbHelper.updateBillItem(tempItem); + } + + if (!mounted) return; + setState(() { + isLoading = false; + }); + + /// 这两个个跳转都有问题,打开app时账单列表页面appbar没有返回箭头,从这里跳过去后就会有了。 + /// Flutter 会根据上下文自动添加一个返回按钮,因为通常当页面不是堆栈中的根页面时,用户期望能够返回到上一个页面。 + /// 即使设置了leading:null也没有效果 + /// + // 新增或修改成功了,跳转到主页面去(homepage默认是账单列表) + // 因为可能是修改(从账单列表来的)或者新增(从新增按钮来的),来源不一样,所以这里不是返回而是替换 + // Navigator.pushAndRemoveUntil( + // context, + // MaterialPageRoute(builder: (context) => const HomePage()), + // ModalRoute.withName('/'), + // ); + + // Navigator.pushReplacement( + // context, + // MaterialPageRoute(builder: (context) => const HomePage()), + // ); + + /// 这个跳转虽然没有appbar没有箭头了,但数据不会重新加载 + /// 因为pop和popUntil这些操作只是从导航堆栈中移除页面,而不会重新创建它们。 + // Navigator.of(context).popUntil((route) => route.isFirst); + + // 2024-05-29 新增新增账单放到账单列表页面去了,所以修改和新增返回就是到账单列表页面去 + Navigator.of(context).pop(true); + } catch (e) { + // 将错误信息展示给用户 + if (!mounted) return; + commonHintDialog(context, "异常警告", e.toString()); + setState(() { + isLoading = false; + }); + return; + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("${widget.billItem != null ? '修改' : '新增'}账单项目"), + actions: [ + ElevatedButton( + onPressed: () async { + if (_formKey.currentState!.saveAndValidate()) { + // 处理表单数据,如保存到数据库等 + saveBillItem(); + } + }, + child: const Text('保存'), + ), + ], + ), + body: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(16.sp), + child: FormBuilder( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + children: [ + Expanded( + flex: 1, + child: FormBuilderChoiceChip( + name: 'item_type', + initialValue: '支出', + // 可让选项居中 + alignment: WrapAlignment.center, + // 选项标签的一些大小修改配置 + labelStyle: TextStyle(fontSize: 10.sp), + labelPadding: EdgeInsets.all(1.sp), + options: const [ + FormBuilderChipOption(value: '支出'), + FormBuilderChipOption(value: '收入'), + ], + decoration: const InputDecoration( + // 取消下划线 + border: InputBorder.none, + ), + onChanged: (String? val) { + if (val != null) { + setState(() { + selectedCategoryType = val; + }); + } + }, + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(), + ]), + ), + ), + Expanded( + flex: 2, + child: FormBuilderDateTimePicker( + name: 'date', + initialEntryMode: DatePickerEntryMode.calendar, + initialValue: DateTime.now(), + inputType: InputType.both, + decoration: const InputDecoration( + // labelText: '时间', + // // 取消下划线 + // border: InputBorder.none, + // 设置透明底色 + filled: true, + fillColor: Colors.transparent, + suffixIcon: Icon(Icons.arrow_drop_down), + // // 后置图标点击清空 + // suffixIcon: IconButton( + // icon: Icon(Icons.close, size: 20.sp), + // onPressed: () { + // _formKey.currentState!.fields['date'] + // ?.didChange(null); + // }, + // ), + ), + keyboardType: TextInputType.datetime, + initialTime: const TimeOfDay(hour: 8, minute: 0), + locale: Localizations.localeOf(context), + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(), + ]), + ), + ), + ], + ), + FormBuilderTextField( + autovalidateMode: AutovalidateMode.always, + name: 'value', + decoration: InputDecoration( + labelText: '金额', + // 设置透明底色 + filled: true, + fillColor: Colors.transparent, + prefixIcon: Text( + '\u{00A5}', // 人民币符号的unicode编码 + style: TextStyle(fontSize: 36.sp, color: Colors.black), + ), + suffixIcon: _amountHasError + ? const Icon(Icons.error, color: Colors.red) + : const Icon(Icons.check, color: Colors.green), + ), + onChanged: (val) { + setState(() { + // 如果金额输入不符合规范,尾部图标会实时切换 + _amountHasError = !(_formKey.currentState?.fields['value'] + ?.validate() ?? + false); + }); + }, + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(), + FormBuilderValidators.numeric(), + ]), + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + ), + FormBuilderTextField( + name: 'item', + decoration: const InputDecoration( + labelText: '项目', + // 设置透明底色 + filled: true, + fillColor: Colors.transparent, + ), + onChanged: (val) { + setState(() {}); + }, + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(), + ]), + // initialValue: '12', + // 2023-12-21 enableSuggestions 设为 true后键盘类型为text就正常了。 + // 2024-05-27 9.3.0 版本了还没修 + enableSuggestions: true, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.done, + ), + FormBuilderChoiceChip( + decoration: const InputDecoration( + labelText: '分类', + // 设置透明底色 + filled: true, + fillColor: Colors.transparent, + ), + name: 'category', + // initialValue: initType(), + initialValue: "餐饮", + // 可让选项居中 + alignment: WrapAlignment.center, + // 选项标签的一些大小修改配置 + labelStyle: TextStyle(fontSize: 10.sp), + labelPadding: EdgeInsets.all(1.sp), + elevation: 5, + // padding: EdgeInsets.all(0.sp), + // 标签之间垂直的间隔 + // runSpacing: 10.sp, + // 标签之间水平的间隔 + // spacing: 10.sp, + options: _categoryChipOptions(), + onChanged: _onChanged, + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(), + ]), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/accounting/bill_report/index.dart b/lib/views/accounting/bill_report/index.dart new file mode 100644 index 0000000..fffa140 --- /dev/null +++ b/lib/views/accounting/bill_report/index.dart @@ -0,0 +1,996 @@ +// ignore_for_file: avoid_print + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_date_pickers/flutter_date_pickers.dart' as dp; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:intl/intl.dart'; +import 'package:month_picker_dialog/month_picker_dialog.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; + +import '../../../common/components/tool_widget.dart'; +import '../../../common/constants.dart'; +import '../../../common/db_tools/db_helper.dart'; +import '../../../models/brief_accounting_state.dart'; + +/// 绘制图表时,用于展示数据需要的结构 +class ChartData { + // 构建实例时注意位置参数的位置 + ChartData(this.x, this.y, [this.text, this.color]); + // 分类 + final String x; + // 分类对应的值 + final double y; + // 分类标签显示的文本 + final String? text; + // 该值用的颜色 + final Color? color; +} + +class BillReportIndex extends StatefulWidget { + const BillReportIndex({super.key}); + + @override + State createState() => _BillReportIndexState(); +} + +class _BillReportIndexState extends State + with SingleTickerProviderStateMixin { + late TooltipBehavior _tooltip; + + final DBHelper _dbHelper = DBHelper(); + // 定义TabController + late TabController _tabController; + + // 账单可查询的范围,默认为当前,查询到结果之后更新 + SimplePeriodRange billPeriod = SimplePeriodRange( + minDate: DateTime.now(), + maxDate: DateTime.now(), + ); + + /// + /// 月度统计相关的变量 + /// + // 被选中的月份(yyyy-MM格式,作为查询条件或者反格式化为Datetime时,手动补上day) + String selectedMonth = DateFormat(constMonthFormat).format(DateTime.now()); + // 月度统计列表数据 + late List monthCounts = []; + // 月度项次列表数据 + late List monthBillItems = []; + // 默认展示支出统计,切换到收入时变为false,也是用于按钮是否可用 + bool isMonthExpendClick = true; + // 是否在加载月度统计数据 + bool isMonthLoading = false; + + /// + /// 年度统计相关的变量(使用两套主要为了在月度做了一些操作之后切到年度,再切到月度时保留之前的操作后结果) + /// + // 被选中的年份(yyyy格式,作为查询条件或者反格式化为Datetime时,手动补上day) + String selectedYear = DateFormat.y().format(DateTime.now()); + late List yearCounts = []; + late List yearBillItems = []; + // 默认展示支出统计,切换到收入时变为false,也是用于按钮是否可用 + bool isYearExpendClick = true; + // 是否在加载年度统计数据 + bool isYearLoading = false; + + @override + void initState() { + _tooltip = TooltipBehavior(enable: true); + + // 初始化TabController + _tabController = TabController(vsync: this, length: 2, initialIndex: 0); + // 监听Tab切换 + _tabController.addListener(_handleTabSelection); + + getBillPeriod(); + + // 初始化时就加载两个月的数据,虽然默认是展示月度,但切换都年度时不用重新初始化。 + // 后续再切换年度月度,都有可见的数据,在没改变选中的年月时不用重新查询。 + handleSelectedMonthChange(); + handleSelectedYearChange(); + + super.initState(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + /// 获取数据库中账单记录的日期起迄范围 + getBillPeriod() async { + var tempPeriod = await _dbHelper.queryDateRangeList(); + setState(() { + billPeriod = tempPeriod; + }); + } + + /// 获取指定月的统计数据(参考微信是指定月前后一起半年数据,做柱状图) + _getMonthCount() async { + // 选中2023-04 + // date 2023-04-01 00:00:01 + DateTime date = + DateTime.tryParse("$selectedMonth-01 00:00:01") ?? DateTime.now(); + + // start=2023-02-01 00:00:01 + var startDate = DateTime(date.year, date.month - 2, date.day); + + // end=2023-07-01 00:00:01 + var endDate = DateTime(date.year, date.month + 3, date.day); + // 如果查询的终止范围超过最新的月份,则修正终止月份为当前月份 + if (endDate.isAfter(billPeriod.maxDate)) { + endDate = billPeriod.maxDate; + startDate = DateTime(endDate.year, endDate.month - 6, endDate.day); + } + + String start = DateFormat(constMonthFormat).format(startDate); + String end = DateFormat(constMonthFormat).format(endDate); + + var temp = await _dbHelper.queryBillCountList( + countType: "month", + startDate: "$start-01", + endDate: "$end-31", + ); + + setState(() { + monthCounts = temp; + }); + } + + /// 获取指定年的统计数据(同上,只查询3年的数据) + _getYearCount() async { + DateTime date = DateTime.tryParse("$selectedYear-01-01") ?? DateTime.now(); + var startDate = DateTime(date.year - 1, date.month, date.day); + var endDate = DateTime(date.year + 1, date.month, date.day); + // 如果查询的终止范围超过最新的月份,则修正终止月份为当前月份 + // 起值超过了有记录的年份,返回结果统计时不会有该年份,柱状图就知道有记录的起止 + if (endDate.isAfter(billPeriod.maxDate)) { + endDate = billPeriod.maxDate; + startDate = DateTime(endDate.year - 2, endDate.month, endDate.day); + } + + String start = DateFormat.y().format(startDate); + String end = DateFormat.y().format(endDate); + + var temp = await _dbHelper.queryBillCountList( + countType: "year", + startDate: "$start-01-01", + endDate: "$end-12-31", + ); + + setState(() { + yearCounts = temp; + }); + } + + // 获取指定月份的详细数据 + _getMonthBillItemList() async { + var temp = await _dbHelper.queryBillItemList( + startDate: "$selectedMonth-01", + endDate: "$selectedMonth-31", + page: 1, + pageSize: 0, + ); + var newData = temp.data as List; + + setState(() { + monthBillItems = newData; + }); + } + + // 获取指定年份的详细数据 + _getYearBillItemList() async { + var temp = await _dbHelper.queryBillItemList( + startDate: "$selectedYear-01-01", + endDate: "$selectedYear-12-31", + page: 1, + pageSize: 0, + ); + var newData = temp.data as List; + + setState(() { + yearBillItems = newData; + }); + } + + /// 切换了选中月份的查询函数 + void handleSelectedMonthChange() async { + if (isMonthLoading) { + return; + } + + setState(() { + isMonthLoading = true; + monthCounts.clear(); + monthBillItems.clear(); + }); + + await _getMonthCount(); + await _getMonthBillItemList(); + + setState(() { + isMonthLoading = false; + }); + } + + /// 切换了选中年份的查询函数 + void handleSelectedYearChange() async { + if (isYearLoading) { + return; + } + + setState(() { + isYearLoading = true; + yearCounts.clear(); + yearBillItems.clear(); + }); + // // 在当前上下文中查找最近的 FocusScope 并使其失去焦点,从而收起键盘。 + // 如果在init之类的地方使用,这个context会报错的 + // FocusScope.of(context).unfocus(); + await _getYearCount(); + await _getYearBillItemList(); + + setState(() { + isYearLoading = false; + }); + } + + /// + /// 处理Tab切换(目前无实际作用) + /// + /// 不做任何处理时,默认点击tab标签切换tab,这里会重复触发??? + /// 这是预期行为,参看:https://github.com/flutter/flutter/issues/13848 + /// + _handleTabSelection() { + // tab is animating. from active (getting the index) to inactive(getting the index) + if (_tabController.indexIsChanging) { + print("点击切换了tab--${_tabController.index}"); + // if (_tabController.index == 1) { + // // 如果是切换了月度统计和年度统计,重新查询 + // print("isYearLoading--------$isYearLoading"); + // _handleSelectedMonthChange(); + // } else { + // print("isMonthLoading--------$isMonthLoading"); + // _handleSelectedYearChange(); + // } + } else { + // tab is finished animating you get the current index + // here you can get your index or run some method once. + } + } + + @override + Widget build(BuildContext context) { + return DefaultTabController( + initialIndex: 0, + length: 2, + child: Scaffold( + // 避免搜索时弹出键盘,让底部的minibar位置移动到tab顶部导致溢出的问题 + resizeToAvoidBottomInset: false, + appBar: AppBar( + backgroundColor: Colors.lightGreen, + title: const Text('账单统计'), + // AppBar的preferredSize默认是固定的(对于标准AppBar来说是kToolbarHeight,56) + // 如果不显示title,可以适当减小 + // toolbarHeight: kToolbarHeight - 36, + // title: null, + bottom: TabBar( + // overlayColor: WidgetStateProperty.all(Colors.lightGreen), + controller: _tabController, + // onTap: (int i) { + // print("当前index${_tabController.index}-------点击的index$i"); + // // 这里没法获取到前一个index是哪一个, + // if (i == 1) { + // _handleSelectedYearChange(); + // } else { + // _handleSelectedMonthChange(); + // } + // }, + // 让tab按钮两边留空,更居中一点 + padding: EdgeInsets.symmetric(horizontal: 0.25.sw, vertical: 5.sp), + tabs: const [ + Tab(text: "月账单"), + Tab(text: "年账单"), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: [ + // 月度账单页 + buildTabBarView('month'), + // 年度账单页 + buildTabBarView('year'), + ], + ), + ), + ); + } + + /// + /// 年度月度公共的方法 + /// + /// 构建年度月度账单Tab页面 + buildTabBarView(String billType) { + bool loadingFlag = ((billType == "month") ? isMonthLoading : isYearLoading); + return Column( + children: [ + buildChangeRow(billType), + loadingFlag + ? buildLoader(loadingFlag) + : Expanded( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + buildCountRow(billType), + buildBarChart(billType), + buildPieChart(billType), + buildRankingTop(billType), + ], + ), + ), + ), + ], + ); + } + + /// 年度月度日期选择行 + /// 按月显示收支列表详情的月度切换按钮和月度收支总计的行 + buildChangeRow(String billType) { + bool isMonth = billType == "month"; + return Container( + height: 36.sp, + color: Colors.lightGreen, // 显示占位用 + child: Row( + children: [ + Expanded( + flex: 2, + child: SizedBox( + width: 100.sp, + child: TextButton.icon( + // 按钮带标签默认icon在前面 + iconAlignment: IconAlignment.end, + onPressed: () { + isMonth + ? showMonthPicker( + context: context, + firstDate: billPeriod.minDate, + lastDate: billPeriod.maxDate, + initialDate: DateTime.tryParse("$selectedMonth-01"), + // 一定要先选择年 + yearFirst: true, + // customWidth: 1.sw, + // 不缩放默认title会溢出 + textScaleFactor: 0.9, // 但这个比例不同设备怎么控制??? + // 不显示标头,只能滚动选择 + // hideHeaderRow: true, + ).then((date) { + if (date != null) { + setState(() { + print(date); + selectedMonth = + DateFormat(constMonthFormat).format(date); + handleSelectedMonthChange(); + }); + } + }) + : showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text("选择年份"), + // content: SizedBox( + // // 需要显示弹窗正文的大小(直接设宽度没什么用,但高度有效) + // height: 300.sp, + // child: YearPicker( + // firstDate: billPeriod.minDate, + // lastDate: billPeriod.maxDate, + // selectedDate: + // DateTime.tryParse("$selectedYear-01-01"), + // onChanged: (DateTime dateTime) { + // // 选中年份之后关闭弹窗,并开始查询年度数据 + // Navigator.pop(context); + // setState(() { + // selectedYear = dateTime.year.toString(); + // handleSelectedYearChange(); + // }); + // }, + // ), + // ), + content: SizedBox( + // 需要显示弹窗正文的大小(直接设宽度没什么用,但高度有效) + height: 300.sp, + child: Expanded( + child: dp.YearPicker.single( + selectedDate: DateTime.tryParse( + "$selectedYear-01-01") ?? + DateTime.now(), + onChanged: (DateTime dateTime) { + // 选中年份之后关闭弹窗,并开始查询年度数据 + Navigator.pop(context); + setState(() { + selectedYear = dateTime.year.toString(); + handleSelectedYearChange(); + }); + }, + firstDate: billPeriod.minDate, + lastDate: billPeriod.maxDate, + // datePickerStyles: dp.DatePickerStyles( + // selectedDateStyle: Theme.of(context) + // .textTheme + // .bodyLarge + // ?.copyWith(color: Colors.blue), + // selectedSingleDateDecoration: + // const BoxDecoration( + // color: Colors.red, + // shape: BoxShape.circle, + // ), + // ), + ), + ), + ), + ); + }, + ); + }, + icon: const Icon( + Icons.keyboard_arrow_down, + color: Colors.white, + ), + label: Text( + isMonth ? selectedMonth : selectedYear, + style: TextStyle(fontSize: 15.sp, color: Colors.white), + ), + ), + ), + ), + SizedBox( + width: 50.sp, + height: 20.sp, + child: ElevatedButton( + // 取掉按钮内边距,或者改到自己想要的大小 + style: ElevatedButton.styleFrom( + // minimumSize: Size.zero, + padding: EdgeInsets.symmetric(horizontal: 5.sp), + // tapTargetSize: MaterialTapTargetSize.shrinkWrap, + // backgroundColor: Colors.lightGreen, + ), + onPressed: isMonth + ? (isMonthExpendClick + ? null + : () { + setState(() { + isMonthExpendClick = !isMonthExpendClick; + }); + }) + : (isYearExpendClick + ? null + : () { + setState(() { + isYearExpendClick = !isYearExpendClick; + }); + }), + autofocus: true, + child: const Text("支出"), + ), + ), + SizedBox(width: 10.sp), + SizedBox( + width: 50.sp, + height: 20.sp, + child: ElevatedButton( + // 取掉按钮内边距,或者改到自己想要的大小 + style: ElevatedButton.styleFrom( + // minimumSize: Size.zero, + padding: EdgeInsets.symmetric(horizontal: 5.sp), + // tapTargetSize: MaterialTapTargetSize.shrinkWrap, + // backgroundColor: Colors.lightGreen, + ), + onPressed: isMonth + ? (!isMonthExpendClick + ? null + : () { + setState(() { + isMonthExpendClick = !isMonthExpendClick; + }); + }) + : (!isYearExpendClick + ? null + : () { + setState(() { + isYearExpendClick = !isYearExpendClick; + }); + }), + child: const Text("收入"), + ), + ), + SizedBox(width: 10.sp), + ], + ), + ); + } + + /// 年度或月度统计行 + buildCountRow(String billType) { + bool isMonth = billType == "month"; + + // 获取支出/收入项次数量字符串 + getCounts(List items, bool isExpend) { + var counts = items.where((e) => e.itemType == (isExpend ? 1 : 0)).length; + return "共${isExpend ? '支出' : '收入'} $counts 笔,合计"; + } + + // 获取支出/收入金额总量字符串 + getTotal(List counts, String date, bool isExpend) { + if (counts.isEmpty) return ""; + + print("getTotal-----------$counts $date"); + // 2024-06-03 统计记录可能没有对应月份的数据。 + // 比如6月1日查看统计,还没有账单项次记录,最新的只有5月份的 + var temp = counts.where((e) => e.period == date).toList(); + if (temp.isNotEmpty) { + return "¥${isExpend ? temp.first.expendTotalValue : temp.first.incomeTotalValue}"; + } + return isExpend ? '暂无支出' : '暂无收入'; + } + + var titleText = isMonth + ? getCounts(monthBillItems, isMonthExpendClick) + : getCounts(yearBillItems, isYearExpendClick); + + var textCount = isMonth + ? getTotal(monthCounts, selectedMonth, isMonthExpendClick) + : getTotal(yearCounts, selectedYear, isYearExpendClick); + + return Container( + color: Colors.lightGreen, + height: 50.sp, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ListTile( + dense: true, + title: Text( + titleText, + style: TextStyle(fontSize: 15.sp, color: Colors.white), + ), + trailing: Text( + textCount, + style: TextStyle(fontSize: 24.sp, color: Colors.white), + ), + ) + ], + ), + ); + } + + /// 绘制收入支出柱状图(指定年度或月度字符串:yaer|month) + buildBarChart(String billType) { + // 不是月度,就是年度 + bool isMonth = billType == "month"; + + return SizedBox( + height: 200.sp, // 图表还是绝对高度吧,如果使用相对高度不同设备显示差异挺大 + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(left: 20.sp, top: 5.sp), + child: Text( + isMonth + ? "${isMonthExpendClick ? '支出' : '收入'}对比¥" + : "${isYearExpendClick ? '支出' : '收入'}对比¥", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16.sp), + ), + ), + Expanded( + child: SfCartesianChart( + // x轴的一些配置 + primaryXAxis: CategoryAxis( + // labelRotation: -60, + // 隐藏x轴网格线 + majorGridLines: MajorGridLines(width: 0.sp), + // 格式化x轴的刻度标签 + axisLabelFormatter: (AxisLabelRenderDetails details) { + // 默认的标签样式继续用,简单修改字体大小即可 + TextStyle newStyle = details.textStyle.copyWith( + fontSize: isMonth ? 8.sp : 12.sp, + ); + + /// 格式化x轴标签日期文字(中文年月太长了) + // 获取当前区域 + Locale locale = Localizations.localeOf(context); + // 获取月份标签,转为日期格式,再转为符合区域格式的日期年月字符串 + var newLabel = ""; + if (isMonth) { + newLabel = DateFormat.yM(locale.toString()).format( + DateTime.tryParse("${details.text}-01") ?? DateTime.now(), + ); + } else { + newLabel = DateFormat.y(locale.toString()).format( + DateTime.tryParse("${details.text}-01-01") ?? + DateTime.now(), + ); + } + return ChartAxisLabel(newLabel, newStyle); + }, + ), + // y轴的一些配置 + primaryYAxis: const NumericAxis( + // 隐藏y轴网格线 + majorGridLines: MajorGridLines(width: 0), + // 不显示y轴标签 + isVisible: false, + ), + // 点击柱子的提示行为 + tooltipBehavior: _tooltip, + // 柱子图数据 + series: >[ + ColumnSeries( + dataSource: isMonth ? monthCounts : yearCounts, + xValueMapper: (BillPeriodCount data, _) => data.period, + yValueMapper: (BillPeriodCount data, _) => + (isMonth ? isMonthExpendClick : isYearExpendClick) + ? data.expendTotalValue + : data.incomeTotalValue, + width: 0.6, // 柱的宽度 + spacing: 0.4, // 柱之间的间隔 + name: isMonth + ? (isMonthExpendClick ? '支出' : '收入') + : (isYearExpendClick ? '支出' : '收入'), + color: const Color.fromRGBO(8, 142, 255, 1), + // 根据索引设置不同的颜色,高亮第三个柱子(索引为2,因为索引从0开始) + pointColorMapper: (BillPeriodCount value, int index) { + if (value.period == + (isMonth ? selectedMonth : selectedYear)) { + return Colors.green; // 高亮颜色 + } else { + return Colors.black12; // 其他柱子的颜色 + } + }, + // 数据标签的配置(默认不显示) + dataLabelSettings: DataLabelSettings( + // 显示数据标签 + isVisible: true, + // 数据标签的位置 + // labelAlignment: ChartDataLabelAlignment.bottom, + // 格式化标签组件(可以换成图标等其他部件) + builder: (dynamic data, dynamic point, dynamic series, + int pointIndex, int seriesIndex) { + var d = (data as BillPeriodCount); + return Text( + isMonth + ? "${isMonthExpendClick ? d.expendTotalValue : d.incomeTotalValue}" + : "${isYearExpendClick ? d.expendTotalValue : d.incomeTotalValue}", + style: TextStyle(fontSize: 10.sp), + ); + }, + ), + // 格式化标签文字字符串 + // dataLabelMapper: (datum, index) { + // return "¥${datum.expendTotalValue}"; + // }, + ) + ], + ), + ), + ], + ), + ); + } + + buildPieChart(String billType) { + // 不是月度,就是年度 + bool isMonth = billType == "month"; + + // 获取支出/收入饼图数据 + getCateCounts(List items, bool isExpend) { + // 先过滤是统计支出还是收入 + var tempList = items.where((e) => e.itemType == (isExpend ? 1 : 0)); + // 总的收入或者支出(分类求占比时的分母) + double total = tempList.fold(0, (sum, item) => sum + item.value); + + // 再按照分类分组 + var groupByCate = groupBy(tempList, (item) => item.category); + // 构建饼图需要的数据结构 + final List chartData = []; + // 最后分组计算累加值 + for (var entry in groupByCate.entries) { + // 存在null值就用未分类代替 + String cate = entry.key ?? "未分类"; + // 分类后的列表 + List itemsForCate = entry.value; + // 计算分类后的累加值 + double cateTotal = itemsForCate.fold( + 0, + (sum, item) => sum + item.value, + ); + + // 添加到图表数据列表中 + chartData.add(ChartData( + cate, + double.parse(cateTotal.toStringAsFixed(2)), + "$cate:${((cateTotal / total) * 100).toStringAsFixed(2)}%", + )); + } + + // 从大到小排个序(???如果只显示top5的话,可以把小于前5的合并到一个“其他”分类去) + chartData.sort((a, b) => b.y.compareTo(a.y)); + + return chartData; + } + + // 如果分类统计的数据为空,就不用显示饼图了 + List data = isMonth + ? getCateCounts(monthBillItems, isMonthExpendClick) + : getCateCounts(yearBillItems, isYearExpendClick); + + if (data.isEmpty) { + return Container(); + } + + return SizedBox( + height: 300.sp, // 图表还是绝对高度吧,如果使用相对高度不同设备显示差异挺大 + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(left: 20.sp, top: 5.sp), + child: Text( + isMonth + ? "${isMonthExpendClick ? '支出' : '收入'}构成" + : "${isYearExpendClick ? '支出' : '收入'}构成", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16.sp), + ), + ), + Expanded( + child: SfCircularChart( + // 点击分类图显示提示 + tooltipBehavior: TooltipBehavior(enable: true), + series: [ + DoughnutSeries( + dataSource: data, + xValueMapper: (ChartData data, _) => data.x, + yValueMapper: (ChartData data, _) => data.y, + // 改变饼在整个图表所占比例(默认80%) + radius: '60%', + // 内圆的占比(越大内部孔就越大) + innerRadius: '50%', + // Segments will explode on tap + explode: true, + // First segment will be exploded on initial rendering + explodeIndex: 1, + // 多于的归位“其他”分类中(有自定义的标签,加上这个显示不太对劲,所以要针对index处理) + groupMode: CircularChartGroupMode.point, + groupTo: 7, + // 用于映射数据源中的文本(更详细的自定义用下面dataLabelSettings的builder) + // dataLabelMapper: (ChartData data, _) => data.text, + // 数据标签的配置(默认不显示) + dataLabelSettings: DataLabelSettings( + // 默认显示标签 + isVisible: true, + // 标签相关使用对应分类图表的颜色 + useSeriesColor: true, + // 智能排列数据标签,避免标签重叠时的交叉。 + labelIntersectAction: LabelIntersectAction.shift, + // 标签显示的位置 + labelPosition: ChartDataLabelPosition.outside, + // 标签和图连接线的设置 + connectorLineSettings: ConnectorLineSettings( + // 指定连接线的形状 + type: ConnectorType.line, + // 指定连接线的长度 + length: '20%', + // 指定连接线的线宽 + width: 1.sp, + ), + // 隐藏值为0的数据 + showZeroValue: false, + // 自定义数据标签的外观 + builder: (dynamic data, dynamic point, dynamic series, + int pointIndex, int seriesIndex) { + var d = (data as ChartData); + // 因为上面groupTo设定为7,这里大于7的都显示其他 + if (pointIndex < 7) { + return Text( + d.text ?? "未分类", + style: TextStyle(fontSize: 10.sp), + ); + } else { + return Text( + "其他", + style: TextStyle(fontSize: 10.sp), + ); + } + }, + ), + ) + ], + ), + ), + ], + ), + ); + } + + /// 按照数值大小倒序查看账单项次 + /// 这里应该是按照category大类分类统计,然后点击查看该分类的账单列表。暂时先这样 + buildRankingTop(String billType) { + bool isMonth = billType == "month"; + + // 按选择类型获取支出或收入的数据列表 + var orderedItems = isMonth + ? (monthBillItems + .where( + (e) => isMonthExpendClick ? e.itemType != 0 : e.itemType == 0) + .toList()) + : (yearBillItems + .where((e) => isYearExpendClick ? e.itemType != 0 : e.itemType == 0) + .toList()); + // 按照值降序排序 + orderedItems.sort((a, b) => b.value.compareTo(a.value)); + + // 年度统计的,只要top10 + if (!isMonth) { + orderedItems = + orderedItems.length > 10 ? orderedItems.sublist(0, 10) : orderedItems; + } + + // 如果没有账单列表数据,显示空提示 + if (orderedItems.isEmpty) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(height: 50.sp), + const Icon(Icons.file_present), + const Text("暂无数据"), + ], + ); + } + + /// 有账单条目列表则创建并显示 + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(left: 20.sp, top: 5.sp, bottom: 10.sp), + child: Text( + isMonth + ? "${isMonthExpendClick ? '支出' : '收入'}排行" + : "${isYearExpendClick ? '支出' : '收入'}排行前十", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16.sp), + ), + ), + ListView.builder( + shrinkWrap: true, // 允许ListView根据内容大小来包裹其内容 + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.all(0), + itemCount: orderedItems.length, + itemBuilder: (BuildContext context, int index) { + BillItem i = orderedItems[index]; + + return Padding( + // 设置内边距 + padding: EdgeInsets.fromLTRB(10.sp, 5.sp, 10.sp, 15.sp), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox(width: 5.sp), + SizedBox( + width: 25.sp, + child: Text("${index + 1}"), + ), + SizedBox(width: 5.sp), + // 后续模拟支出收入分类的图标 + Icon(Icons.shopping_cart, color: Colors.orange[300]!), + SizedBox(width: 5.sp), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + i.item, + softWrap: true, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontSize: 14.sp, + color: Theme.of(context).primaryColor, + ), + ), + RichText( + softWrap: true, + overflow: TextOverflow.ellipsis, + maxLines: 1, + text: TextSpan( + children: [ + // 为了分类占的宽度一致才用的,只是显示的话可不必 + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: 50.sp), + child: Text( + i.category ?? "<未分类>", + style: TextStyle( + fontSize: 10.sp, + ), + ), + ), + ), + TextSpan( + text: i.date, + style: TextStyle( + color: Colors.black, + fontSize: 10.sp, + ), + ), + ], + ), + ), + // // 用上面那个一行更好看 + // Text( + // i.category ?? "<未分类>", + // softWrap: true, + // overflow: TextOverflow.ellipsis, + // maxLines: 1, + // style: TextStyle(fontSize: 10.sp), + // ), + // Text( + // "${i.date}__${i.gmtModified ?? ''}", + // softWrap: true, + // overflow: TextOverflow.ellipsis, + // maxLines: 1, + // style: TextStyle(fontSize: 10.sp), + // ) + ], + ), + ), + // 金额这里固定最大99999.99吧 + SizedBox( + width: 90.sp, + child: Text( + "¥${i.value}", + style: TextStyle(fontSize: 15.sp), + softWrap: true, + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.end, + ), + ), + ], + ), + ); + + /// ListTile dense为true默认高度48(否则为56),导致这个card高度56(64),有些高了 + // return Card( + // // margin: EdgeInsets.all(5.sp), // 外边距 + // elevation: 5, + // color: Colors.blueGrey, + // child: ListTile( + // dense: true, + // title: Text( + // "${index + 1} ${i.item}", + // softWrap: true, + // overflow: TextOverflow.ellipsis, + // maxLines: 1, + // style: TextStyle(fontSize: 15.sp), + // ), + // trailing: Text( + // "¥${i.value}", + // style: TextStyle(fontSize: 15.sp), + // ), + // ), + // ); + }, + ), + ], + ); + } +} diff --git a/lib/views/accounting/index.dart b/lib/views/accounting/index.dart new file mode 100644 index 0000000..e0b4b8f --- /dev/null +++ b/lib/views/accounting/index.dart @@ -0,0 +1,991 @@ +// ignore_for_file: avoid_print +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import 'package:collection/collection.dart'; +import 'package:intl/intl.dart'; +import 'package:month_picker_dialog/month_picker_dialog.dart'; + +import '../../common/components/tool_widget.dart'; +import '../../common/constants.dart'; +import '../../common/db_tools/db_helper.dart'; +import '../../models/brief_accounting_state.dart'; + +import 'bill_item_modify/index.dart'; +import 'bill_report/index.dart'; +import 'mock_data/index.dart'; +import 'widgets/bottom_sheet_option_picker.dart'; + +/// 2024-05-28 +/// 账单列表,按月查看 +/// 默认显示当前月的所有账单项次,并额外显示每天的总计的支出和收入; +/// 点击选中年月日期,可切换到其他月份;选中的月份所在行有当月总计的支出和收入。 +/// 在显示选中月日期的清单时,如果有的话,可以上拉加载上一个月的项次数据,下拉加载后一个月的项次数据; +/// 如果当前加载的账单项次不止一个月的数据,则在滚动时大概估算到当前展示的是哪一个月的项次,来更新显示的选中日期; +/// 实现逻辑: +/// 主要是绘制好每月项次列表后,保留每个月占用的总高度,存入数组; +/// 根据滚动控制器得到当前已经加载的高度,和保留每个月总高度的对象列表进行比较; +/// 如果“上一个的累加高度 <= 已加载的高度 < 当前的累加高度”,则当前月就是要展示的月份 +/// 两点注意: +/// 1 存每月列表组件高度的数组存的是月份排序后的累加高度: +/// [{'2024-03': 240}, // 3月份组件总高度 240 +/// {'2024-02': 490}, // 4月份组件总高度 490-240=250 +/// {'2024-01': 630}, // 5月份组件总高度 630-490=140 +/// {'2023-12': 670}, // ... +/// // ... 更多的月份数据]; +/// 2 滚动控制器总加载的高度和实际组件逐个计算的高度不一致,原因不明??? +class BillItemIndex extends StatefulWidget { + const BillItemIndex({super.key}); + + @override + State createState() => _BillItemIndexState(); +} + +class _BillItemIndexState extends State { + final DBHelper _dbHelper = DBHelper(); + // 账单项次的列表滚动控制器 + ScrollController scrollController = ScrollController(); + + // 是否查询账单项次中 + bool isLoading = false; + // 单纯的账单条目列表 + List billItems = []; + // 按日分组后的账单条目对象(key是日期,value是条目列表) + Map> billItemGroupByDayMap = {}; + + // 2024-05-27 因为默认查询有额外的分组统计等操作, + // 所以关键字查询条目的展示要和默认的区分开来 + bool isQuery = false; + // 关键字输入框控制器 + TextEditingController searchController = TextEditingController(); + + // 账单可查询的范围,默认为当前,查询到结果之后更新 + SimplePeriodRange billPeriod = SimplePeriodRange( + minDate: DateTime.now(), + maxDate: DateTime.now(), + ); + + // 被选中的月份(yyyy-MM格式,作为查询条件或者反格式化为Datetime时,手动补上day) + String selectedMonth = DateFormat(constMonthFormat).format(DateTime.now()); + + // 虽然是已查询的最大最小日期,但逻辑中只关注年月,所以日最好存1号,避免产生影响 + DateTime minQueryedDate = DateTime.now(); + DateTime maxQueryedDate = DateTime.now(); + + // 用户滑动的滚动方向,往上拉是up,往下拉时down,默认为none + // 往上拉到头时获取更多数据就是取前一个月的,往下拉到头获取更多数据就是后一个月的 + String scollDirection = "none"; + + // 用一个map来保存每个月份的条目数据组件的总高度 + // 如果加载了多个月份的数据,可以用列表已滚动的高度和每个月的组件总高度进行对比,得到当前月份 + List> monthlyWidgetHeights = []; + + var categoryList = [ + // 饮食 + "三餐", "外卖", "零食", "夜宵", "烟酒", "饮料", + // 购物 + "购物", "买菜", "日用", "水果", "买花", "服装", + // 娱乐 + "娱乐", "电影", "旅行", "运动", "纪念", "充值", + // 住、行 + "交通", "住房", "房租", "房贷", + // 生活 + "理发", "还款", + ]; + + // 选中查询的类型,默认是全部,可切换到“支出|收入|全部” + String selectedType = "全部账单"; + + // 2024-06-26 是否显示,我自己要用的从json文件导入账单列表数据的按钮 + bool isShowMock = false; + + @override + void initState() { + super.initState(); + + // 2024-05-25 初始化查询时就更新已查询的最大日期和最小日期为当天所在月份的1号(后续用到的地方也只关心年月) + maxQueryedDate = DateTime.tryParse("$selectedMonth-01") ?? DateTime.now(); + minQueryedDate = DateTime.tryParse("$selectedMonth-01") ?? DateTime.now(); + + print("初始化时的最大最小查询日期-------------$maxQueryedDate $minQueryedDate"); + + getBillPeriod(); + loadBillItemsByMonth(); + + scrollController.addListener(_scrollListener); + } + + @override + void dispose() { + scrollController.removeListener(_scrollListener); + scrollController.dispose(); + searchController.dispose(); + super.dispose(); + } + + /// 获取数据库中账单记录的日期起迄范围 + getBillPeriod() async { + var tempPeriod = await _dbHelper.queryDateRangeList(); + setState(() { + billPeriod = tempPeriod; + }); + } + + /// 查询指定月份账单项次列表 + /// 获取系统当月的所有账单条目查询出来(这样每日、月度统计就是正确的), + /// 下滑显示完当月数据化,加载上一个月的所有数据出来 + /// 2024-05-27 这个查询不带关键字,有专门带关键字的查询函数 + Future loadBillItemsByMonth() async { + if (isLoading) return; + + setState(() { + isLoading = true; + }); + + CusDataResult temp = await _dbHelper.queryBillItemList( + startDate: "$selectedMonth-01", + endDate: "$selectedMonth-31", + page: 1, + pageSize: 0, + ); + + var newData = temp.data as List; + + setState(() { + // 2024-05-24 这里不能直接添加,还需要排序,不然上拉后下拉日期新的列表在日期旧的列表后面 + if (scollDirection == "down") { + billItems.insertAll(0, newData); + } else { + billItems.addAll(newData); + } + + // 加载完所有项次列表之后,要计算每个月项次组件的总高度,用于后续计算滑动所在的月份 + _computeMonthWidgetHeights(); + + // 按照每天进行项次分组,方便后续计算每日的总支出/收入 + billItemGroupByDayMap = groupBy(billItems, (item) => item.date); + + isLoading = false; + }); + } + + // 查询选中月份的总支出/收入信息 + Future?> loadBillCountByMonth() async { + try { + return await _dbHelper.queryBillCountList( + startDate: "$selectedMonth-01", + endDate: "$selectedMonth-31", + ); + } catch (e) { + print(e); + return null; + } + } + + /// 2024-05-28 根据已经滚动的高度和每个月份所在列表子组件的总高度的map,获得当前月份 + // 假设列表项高度是固定的,并且 monthlyWidgetHeights 是每月列表的累积高度 + String _getCurrentMonth(double scrollPosition) { + // 初始化一个变量来存储上一个月份的累积高度 + double prevCumulativeHeight = 0; + + // 遍历月份累积高度列表 + for (int i = 0; i < monthlyWidgetHeights.length; i++) { + // 当前月份的累积高度 + double currentCumulativeHeight = monthlyWidgetHeights[i].values.first; + + // 检查滚动位置是否在当前月份和前一个月份之间 + // 注意:当i == 0时,prevCumulativeHeight为0,这对应于列表的顶部 + if (scrollPosition >= prevCumulativeHeight && + scrollPosition < currentCumulativeHeight) { + // 滚动位置位于当前月份内,返回当前月份 + return monthlyWidgetHeights[i].keys.first; + } + + // 更新上一个月份的累积高度为当前月份的累积高度 + prevCumulativeHeight = currentCumulativeHeight; + } + + // 如果滚动位置超过所有月份的高度,返回最后一个月份 + // 注意:这通常不会发生,除非滚动位置在列表底部之外,但这里作为一个安全网 + return monthlyWidgetHeights.last.keys.first; + } + + /// listview 滚动的侦听器,有以下功能: + /// 1、上拉滚动到当月数据项次结束,则加载上一个月的数据;同理下拉到头加载下一个月的数据; + /// 2、如果列表中有多个月份的数据,根据每个月份组件的累计高度和已加载的列表高度比较,得到当前显示的列表所在的月份。 + void _scrollListener() { + if (isLoading) return; + + // 最大滚动范围(注意,这个 maxScrollExtent 的值在滚动过程中是变化的) + final maxScrollExtent = scrollController.position.maxScrollExtent; + // 已经滚动的高度 + final currentPosition = scrollController.position.pixels; + // 已经滚动的高度(这两个是一样的???应该只是方向一致时) + final offset = scrollController.offset; + // 是否在顶部(最小滚动位置) + final atEdge = scrollController.position.atEdge; + // 是否超出滚动范围 + final outOfRange = scrollController.position.outOfRange; + + // print("offset---$offset,currentPosition-$currentPosition"); + + // 根据已经滚动的高度和提前存好账单列表组件的累积高度计算出当前展示的项次是哪个月份的 + String currentMonth = _getCurrentMonth(currentPosition); + // 如果有多个月份的数据在滚动时,估算显示当前月份,并更新显示 + setState(() { + selectedMonth = currentMonth; + }); + + /// 滚动到顶部,加载下一个月数据 + // 但是已经达到了账单记录的最大日期和最小日期月份,则不再加载了。 + if (atEdge && currentPosition == 0) { + // 如果要查询的下一个月在已查询的最大月份之前,则更新下一个月为已查询最大月 + // 比如一直往上拉,已有202304-202308的数据,因为往上拉,此时被选中的月份是2023-04。 + // 现在往下拉到顶,应该查询2022309的数据,因为选中的是2023-04,不做任何处理的话查询的实际是202305的值,这不对。 + // 所以直接使用已查询的最大日期去+1查最新数据,并更新选中月份 + DateTime nextMonthDate = DateTime( + maxQueryedDate.year, + maxQueryedDate.month + 1, + maxQueryedDate.day, + ); + + String nextMonth = DateFormat(constMonthFormat).format(nextMonthDate); + // 如果当前月份的下一月的1号已经账单中最大日期了,就算到顶了也没有数据可加载乐 + if (nextMonthDate.isAfter(billPeriod.maxDate)) { + setState(() { + selectedMonth = DateFormat(constMonthFormat).format(maxQueryedDate); + }); + return; + } + + // 正常下拉加载更新的数据,要更新当前选中值和最大查询日期 + setState(() { + selectedMonth = nextMonth; + maxQueryedDate = nextMonthDate; + scollDirection = "down"; + loadBillItemsByMonth(); + }); + } else if (atEdge && !outOfRange && offset >= maxScrollExtent) { + /// 滚动到底部,查询下一个月的数据(看往上拉的逻辑说明) + + DateTime lastMonthDate = DateTime( + minQueryedDate.year, + minQueryedDate.month - 1, + minQueryedDate.day, + ); + String lastMonth = DateFormat(constMonthFormat).format(lastMonthDate); + + // 如果当前月份已经账单中最大日期了,到顶了也不再加载 + if (lastMonthDate.isBefore(billPeriod.minDate)) { + setState(() { + selectedMonth = DateFormat(constMonthFormat).format(minQueryedDate); + }); + return; + } + + // 上拉还有旧数据可查就继续查询 + setState(() { + selectedMonth = lastMonth; + minQueryedDate = lastMonthDate; + scollDirection = "up"; + loadBillItemsByMonth(); + }); + } + } + + // 查询指定月份的账单项次数据 + // 在切换了当前月份等情况下回用到 + void handleSearch() { + setState(() { + billItems.clear(); + scollDirection == "none"; + }); + // 在当前上下文中查找最近的 FocusScope 并使其失去焦点,从而收起键盘。 + FocusScope.of(context).unfocus(); + + loadBillItemsByMonth(); + } + + // 关键字查询和带统计值得查询不一样,专门函数区分,避免异动之前的逻辑 + // 带关键字查询的就没有滚动加载更多了,注意查询结果特别大的时候,可能会有性能问题??? + void handleKeywordSearch({pageSize = 0}) async { + // 在当前上下文中查找最近的 FocusScope 并使其失去焦点,从而收起键盘。 + // 如果要在init等地方使用,不能加这个,因为那时候还没有context + FocusScope.of(context).unfocus(); + + if (isLoading) return; + + setState(() { + isLoading = true; + }); + + setState(() { + billItems.clear(); + scollDirection == "none"; + }); + + CusDataResult temp = await _dbHelper.queryBillItemList( + itemKeyword: searchController.text.trim(), + page: 1, + pageSize: pageSize, + ); + + var newData = temp.data as List; + + setState(() { + // 关键字查询结果,直接添加,没有其他顺序, + billItems.addAll(newData); + // 因为和带统计的账单项次复用构建列表组件,所以这个分组还是要有 + billItemGroupByDayMap = groupBy(billItems, (item) => item.date); + isLoading = false; + }); + } + + /* + // 2024-05-27 当前带统计的每月子组件的结构如下,要计算其总高度 + Card( // 边框有 8 + child: Column( + children: [ + ListTile(dense: true), // 48 + // Divider(), // 16 + Column( + children: [ + GestureDetector(child: ListTile()), // 64 固定有title和subtitle + GestureDetector(child: ListTile()), // 64 + ... + ])])); + 不过累加的值和实际的值对不上。 + 比如5月测试数据,额外的ListTile*15,原本GestureDetector(child:ListTile())*42, + 计算的高度:15*(48+16+8[card的边框])+56*42=3432 + 实际ListView滚动的总高度:3030 + */ + _computeMonthWidgetHeights() { +// 每次都要重新计算,避免累加 + monthlyWidgetHeights.clear(); + + // 按照月份分组 + var temp = groupBy(billItems, (item) => item.date.substring(0, 7)); + + var monthHegth = 0.0; + for (var i = 0; i < temp.entries.length; i++) { + var entry = temp.entries.toList()[i]; + + // 处理每个月份的数据 + String tempMonth = entry.key; + // 每个月实际拥有的账单项次数量 + List tempMonthItems = entry.value; + + // 按天分组统计支出收入的额外项次的数量 + var extraItemsLength = + groupBy(tempMonthItems, (item) => item.date).entries.length; + + // 当前月份的组件总高度 + monthHegth += tempMonthItems.length * 64.0 + extraItemsLength * (48 + 8); + // 实际测试,滚动的值比计算的值要小一些: + // 第1、2个月份计算结果差402,第3个月差388,第4、5、6个月346,第7个月差332,第8个月318…… + // 没有继续下去,原因不明???暂时第一个月少算402,后面的几十个像素基本对得上。 + if (i == 0) { + monthHegth -= 402; + } + + // print( + // "$tempMonth---原数量${tempMonthItems.length}- 额外数量 $extraItemsLength 累加高度$monthHegth"); + + // 注意,这里存的是每个月的累加高度 + monthlyWidgetHeights.add({tempMonth: monthHegth}); + } + + // print("每月组件的高度----$monthlyWidgetHeights"); + } + + void showBottomSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, // 如果选项很多,启用滚动控制 + builder: (BuildContext context) { + return BottomSheetOptionPicker( + options: categoryList, + onConfirm: (selectedOption) { + Navigator.pop(context, selectedOption); + }, + ); + }, + ).then((value) { + print('Selected: $value'); + // 在这里处理选中的值 + if (value != null) { + setState(() { + selectedType = value; + }); + } + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + // 避免搜索时弹出键盘,让底部的minibar位置移动到tab顶部导致溢出的问题 + resizeToAvoidBottomInset: false, + // 这里也可以不用appbar??? + appBar: AppBar( + title: GestureDetector( + onLongPress: () async { + // 长按之后,先改变是否使用作者应用的标志 + setState(() { + isShowMock = !isShowMock; + }); + }, + child: const Text("极简记账"), + ), + // 明确说明不要返回箭头,避免其他地方使用push之后会自动带上返回箭头 + // leading: const Icon(Icons.arrow_back), + backgroundColor: Colors.lightGreen, + actions: [ + if (isShowMock) + TextButton( + onPressed: () async { + setState(() { + billItems.clear(); + scollDirection == "none"; + isLoading = true; + }); + + var importRst = await loadBillIeamFromAssets(); + + setState(() { + isLoading = false; + }); + loadBillItemsByMonth(); + + if (!importRst) { + // ignore: use_build_context_synchronously + commonExceptionDialog(context, "导入失败", "选中的json文件格式不正确"); + } + }, + child: const Text("Mock"), + ), + // ElevatedButton( + // onPressed: showBottomSheet, + // child: const Text('demo'), + // ), + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const BillEditPage(), + ), + ).then((value) { + // 如果有新增成功,则重新查询当前月份数据 + print("新增billitem的返回值---$value"); + if (value != null && value) { + setState(() { + handleSearch(); + }); + } + }); + }, + icon: Icon( + Icons.add_outlined, + color: Theme.of(context).primaryColor, + ), + ), + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const BillReportIndex(), + ), + ); + }, + icon: Icon( + Icons.bar_chart_outlined, + color: Theme.of(context).primaryColor, + ), + ), + // TextButton.icon( + // onPressed: () { + // Navigator.push( + // context, + // MaterialPageRoute( + // builder: (context) => const BillReportIndex(), + // ), + // ); + // }, + // label: const Text('统计'), + // icon: Icon(Icons.arrow_forward_ios, size: 12.sp), + // iconAlignment: IconAlignment.end, + // ), + ], + ), + + body: SafeArea( + child: Column( + children: [ + /// test 已查询的月度范围 + buildQueryRangeRow(), + + /// 2024-05-27 展开关键字查询时,隐藏当月的统计行 + if (!isQuery) buildMonthCountRow(), + + /// 当选中了关键字查询,才展示条目关键字搜索行 + if (isQuery) buildSearchRow(), + + /// 构建账单条目列表(关键字查询和默认统计查询都用这个,内部有区分) + buildBillItemList(), + ], + ), + ), + ); + } + + /// 显示已加载的账单项次范围,或者查询时显示可查询的范围 + buildQueryRangeRow() { + String locale = Localizations.localeOf(context).toString(); + return Container( + height: 50.sp, + // color: Colors.amberAccent, + color: Colors.lightGreen, + child: Padding( + padding: EdgeInsets.fromLTRB(15.sp, 0, 4, 0), + child: Row( + children: [ + Expanded(flex: 3, child: Text(isQuery ? "可查询的数据范围" : "已加载的数据范围")), + Expanded( + flex: 4, + // 不是关键字查询时展示范围为滚动查询的范围,是关键字查询时就是账单起止范围 + child: Text( + !isQuery + ? "${DateFormat.yM(locale).format(minQueryedDate)}~${DateFormat.yM(locale).format(maxQueryedDate)}" + : "${DateFormat.yM(locale).format(billPeriod.minDate)}~${DateFormat.yM(locale).format(billPeriod.maxDate)}", + style: TextStyle(fontSize: 13.sp, fontWeight: FontWeight.bold), + ), + ), + // 2024-05-27 点击按钮切换是否显示关键字查询区块 + SizedBox( + width: 60.sp, + child: IconButton( + onPressed: () { + setState(() { + isQuery = !isQuery; + // 如果点击之后是展开或者收起查询区域,都要重置已经输入的关键字 + searchController.text = ""; + // 如果是收起查询区域,则要重新展示当前月的列表及统计数据 + if (!isQuery) { + handleSearch(); + } else { + // 如果是进入了关键字查询,默认展示10条 + // 没有这个查询,切到关键字查询时显示的是之前带统计的已查询的所有列表 + // _handleKeywordSearch(pageSize: 10); + } + }); + }, + icon: Icon(isQuery ? Icons.clear : Icons.search), + ), + ), + ], + ), + ), + ); + } + + /// 按月显示收支列表详情的月度切换按钮和月度收支总计的行 + buildMonthCountRow() { + return Container( + height: 50.sp, + // color: Colors.amber, // 显示占位用 + color: Colors.grey[400], // 显示占位用 + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(flex: 2, child: _buildCurrentMonthButton()), + Expanded(flex: 3, child: _buildCurrentMonthCountTile()), + ], + ), + ); + } + + // 可切换的被选中的月份按钮 + _buildCurrentMonthButton() { + return SizedBox( + width: 100.sp, + // 按钮带标签默认icon在前面,所以使用方向组件改变方向 + // 又因为ui和intl都有TextDirection类,所以显示什么ui的导入 + child: Directionality( + textDirection: ui.TextDirection.rtl, + child: TextButton.icon( + onPressed: () { + showMonthPicker( + context: context, + firstDate: billPeriod.minDate, + lastDate: billPeriod.maxDate, + initialDate: DateTime.tryParse("$selectedMonth-01"), + // 一定要先选择年 + yearFirst: true, + // customWidth: 1.sw, + // 不缩放默认title会溢出 + textScaleFactor: 0.9, // 但这个比例不同设备怎么控制??? + // 不显示标头,只能滚动选择 + // hideHeaderRow: true, + ).then((date) { + if (date != null) { + setState(() { + print(date); + selectedMonth = DateFormat(constMonthFormat).format(date); + maxQueryedDate = + DateTime.tryParse("$selectedMonth-01") ?? DateTime.now(); + minQueryedDate = + DateTime.tryParse("$selectedMonth-01") ?? DateTime.now(); + handleSearch(); + }); + } + }); + }, + icon: const Icon(Icons.arrow_drop_down), + label: Text( + selectedMonth, + style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold), + ), + ), + ), + ); + } + + // 这里是月度账单下拉后查询的总计结果,理论上只存在1条,不会为空。 + _buildCurrentMonthCountTile() { + return FutureBuilder?>( + future: loadBillCountByMonth(), + builder: (BuildContext context, + AsyncSnapshot?> snapshot) { + List children; + // 有数据 + if (snapshot.hasData) { + var list = snapshot.data!; + if (list.isNotEmpty) { + children = [ + Text( + "支出 ¥${list[0].expendTotalValue} 收入 ¥${list[0].incomeTotalValue}", + style: TextStyle(fontSize: 12.sp), + textAlign: TextAlign.end, + ), + ]; + } else { + children = [const Text("该月份无账单")]; + } + } else if (snapshot.hasError) { + // 有错误 + children = [ + const Icon(Icons.error_outline, color: Colors.red, size: 30), + ]; + } else { + // 加载中 + children = const [ + SizedBox(width: 30, height: 30, child: CircularProgressIndicator()), + ]; + } + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: children, + ), + ); + }, + ); + } + + /// 条目关键字搜索行 + buildSearchRow() { + return Container( + height: 50.sp, + // color: Colors.amberAccent, + color: Colors.grey[400], // 显示占位用 + child: Padding( + padding: EdgeInsets.fromLTRB(4.sp, 0, 4, 0), + child: Row( + children: [ + Expanded( + flex: 3, + child: TextField( + controller: searchController, + decoration: const InputDecoration( + hintText: "输入关键字进行查询", + // 设置透明底色 + filled: true, + fillColor: Colors.transparent, + isDense: true, + // border: OutlineInputBorder( + // borderRadius: BorderRadius.circular(10.0), + // ), + ), + ), + ), + Expanded( + flex: 1, + child: TextButton( + onPressed: handleKeywordSearch, + child: const Text("搜索"), + ), + ) + ], + ), + ), + ); + } + + /// 构建收支条目列表(都是完整月份的列表加在一起的) + buildBillItemList() { + return Expanded( + child: ListView.builder( + itemCount: billItemGroupByDayMap.entries.length, + itemBuilder: (context, index) { + if (index == billItemGroupByDayMap.entries.length) { + return buildLoader(isLoading); + } else { + return _buildBillItemCard(index); + } + }, + // 因为列表是复用的,所有关键字查询展示时不要启用滚动控制器 + controller: isQuery ? null : scrollController, + ), + ); + } + + // 构建账单项次条目组件(Card中有手势包裹的Tile或者Row) + _buildBillItemCard(int index) { + // 获取当前分组的日期和账单项列表 + var entry = billItemGroupByDayMap.entries.elementAt(index); + String date = entry.key; + List itemsForDate = entry.value; + + // 计算每天的总支出/收入 + double totalExpend = 0.0; + double totalIncome = 0.0; + for (var item in itemsForDate) { + if (item.itemType != 0) { + totalExpend += item.value; + } else { + totalIncome += item.value; + } + } + + return Card( + child: Column( + children: [ + ListTile( + title: Text(date, style: TextStyle(fontSize: 15.sp)), + trailing: isQuery + ? null + : Text( + '支出 ¥${totalExpend.toStringAsFixed(2)} 收入 ¥${totalIncome.toStringAsFixed(2)}', + style: TextStyle( + color: Theme.of(context).primaryColor, + fontSize: 13.sp, + ), + ), + // tileColor: Colors.lightGreen, + tileColor: Colors.grey[300], + dense: true, + // 可以添加副标题或尾随图标等 + ), + // const Divider(), // 可选的分隔线 + // 为每个BillItem创建一个Tile + Column( + children: ListTile.divideTiles( + context: context, + tiles: itemsForDate + .map( + (item) => _buildItemGestureDetector(item), + ) + .toList(), + ).toList(), + ), + ], + ), + ); + } + + GestureDetector _buildItemGestureDetector(BillItem item) { + return GestureDetector( + // 暂定长按删除弹窗、双击跳到修改 + // ListTile 没有双击事件,所以包裹一个手势 + onDoubleTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BillEditPage(billItem: item), + ), + ).then((value) { + // 如果有修改成功,则重新查询当前月份数据 + print("x修改------billitem的返回值---$value"); + if (value != null && value) { + setState(() { + handleSearch(); + }); + } + }); + }, + onLongPress: () { + // 不管如何,关闭弹窗后都失去焦点收起键盘 + FocusScope.of(context).unfocus(); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("删除提示"), + content: SizedBox( + height: 100.sp, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "确定删除选中的条目:\n ${item.date}", + style: TextStyle(fontSize: 15.sp), + ), + Text( + "\t\t\t\t${item.itemType != 0 ? '支出' : '收入'}: ${item.category ?? ''} ${item.item} ${item.value}", + style: TextStyle(fontSize: 12.sp), + ) + ], + ), + ), + actions: [ + ElevatedButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text("取消"), + ), + TextButton( + onPressed: () async { + await _dbHelper.deleteBillItemById(item.billItemId); + if (mounted) { + // ignore: use_build_context_synchronously + Navigator.of(context).pop(true); + } + }, + child: const Text("确定"), + ), + ], + ); + }, + ).then((value) { + // 成功删除就重新查询 + if (value != null && value) { + setState(() { + // 这里不区分带统计和不带统计的是因为,如果是关键字查询删除之后,重新查询关键字为空,则默认查询所有数据。 + // 如果数据较多就比较大,保留之前带统计的查询就不会太大,而且顺序也是没问题的。 + handleSearch(); + }); + } + }); + }, + child: _buildItem(item, type: "tile"), + ); + } + + _buildItem(BillItem item, {String type = 'tile'}) { + if (type == 'tile') { + return ListTile( + dense: true, + // 和之前使用RichText效果类似(子标题固定空字符串而不是null,是为了计算组件高度时能统一,否则高度不一致) + title: Text(item.item, style: TextStyle(fontSize: 15.sp)), + subtitle: Text(item.category ?? '<未分类>'), + trailing: Text( + '${item.itemType == 0 ? '+' : '-'}${item.value.toStringAsFixed(2)}', + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.bold, + color: item.itemType != 0 ? Colors.black : Colors.green, + ), + ), + ); + } + + // 如果觉得默认的tile不够紧凑,可以试一下自定义的其他组件 + return Padding( + // 设置内边距 + padding: EdgeInsets.fromLTRB(10.sp, 5.sp, 10.sp, 5.sp), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + text: TextSpan( + children: [ + // 为了分类占的宽度一致才用的,只是显示的话可不必 + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: 60.sp), + child: Text( + "<${item.category ?? '未分类'}>", + style: TextStyle( + color: Theme.of(context).primaryColor, + fontSize: 12.sp, + ), + ), + ), + ), + // TextSpan( + // text: "<${item.category ?? '未分类'}> ", + // style: TextStyle( + // color: Theme.of(context).primaryColor, + // fontSize: 12.sp, + // ), + // ), + TextSpan( + text: item.item, + style: TextStyle(color: Colors.blue, fontSize: 15.sp), + ), + ], + ), + ), + + /// 另外的分类和条目展示 + // Text( + // item.item, + // softWrap: true, + // overflow: TextOverflow.ellipsis, + // maxLines: 1, + // style: TextStyle( + // fontSize: 12.sp, + // fontWeight: FontWeight.bold, + // ), + // ), + // Text( + // item.category ?? '<未分类>', + // style: TextStyle(fontSize: 12.sp), + // ), + /// 如果没有按日期分组的话,就可以单独列日期行 + // Text( + // "${item.date}___created:${item.gmtModified ?? ''}", + // softWrap: true, + // overflow: TextOverflow.ellipsis, + // maxLines: 1, + // style: TextStyle(fontSize: 10.sp), + // ) + ], + ), + ), + Expanded( + child: Text( + "¥${item.value.toStringAsFixed(2)}", + style: TextStyle(fontSize: 15.sp), + textAlign: TextAlign.end, + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/accounting/widgets/bottom_sheet_option_picker.dart b/lib/views/accounting/widgets/bottom_sheet_option_picker.dart new file mode 100644 index 0000000..b74164a --- /dev/null +++ b/lib/views/accounting/widgets/bottom_sheet_option_picker.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +class BottomSheetOptionPicker extends StatefulWidget { + final List options; + final ValueChanged onConfirm; + + const BottomSheetOptionPicker({ + super.key, + required this.options, + required this.onConfirm, + }); + + @override + State createState() => _BottomSheetOptionPickerState(); +} + +class _BottomSheetOptionPickerState extends State { + String? _selectedOption; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 0.6.sh, + child: Column( + children: [ + Container( + height: 0.1.sh, + color: Colors.amber, + child: const Text("选择分类"), + ), + Container( + height: 0.4.sh, + color: Colors.lightBlueAccent[50], + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + /// 一行多个Container + Wrap( + spacing: 8.0, // 子元素之间的间距 + runSpacing: 4.0, // 子元素行之间的间距 + children: widget.options.map((option) { + return InkWell( + onTap: () { + setState(() { + _selectedOption = option; + }); + }, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all( + color: Colors.grey.withOpacity(0.5), + width: 1, + ), + borderRadius: BorderRadius.circular(4), + color: _selectedOption == option + ? Colors.lightBlue + : null, + ), + child: Text( + option, + // style: TextStyle( + // color: _selectedOption == option + // ? Colors.black + // : null, + // ), + ), + ), + ); + }).toList(), + ), + + /// 一行多个listtile + // Wrap( + // spacing: 8.0, // 子元素之间的间距 + // runSpacing: 4.0, // 子元素行之间的间距 + // children: widget.options.map((option) { + // bool isSelected = option == _selectedOption; + // return SizedBox( + // width: 0.2.sw, + // child: ListTile( + // title: Text(option), + // selected: isSelected, + // tileColor: isSelected + // ? Colors.black.withOpacity(0.5) + // : null, // 选中时改变颜色 + // onTap: () { + // setState(() { + // _selectedOption = option; + // }); + // }, + // ), + // ); + // }).toList(), + // ), + + /// 一行一个listtile + // ...widget.options.map((option) { + // bool isSelected = option == _selectedOption; + // return ListTile( + // title: Text(option), + // selected: isSelected, + // onTap: () { + // setState(() { + // _selectedOption = option; + // }); + // }, + // ); + // }), + ], + ), + ), + ), + Expanded( + child: Container( + color: Colors.lightGreen, + child: Padding( + padding: EdgeInsets.all(0.sp), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ElevatedButton( + onPressed: () { + widget.onConfirm(null); + }, + child: const Text('取消'), + ), + SizedBox(width: 10.sp), + ElevatedButton( + onPressed: () { + if (_selectedOption != null) { + widget.onConfirm(_selectedOption!); + } + }, + child: const Text('确定'), + ), + SizedBox(width: 10.sp), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/accounting/widgets/year_month_picker.dart b/lib/views/accounting/widgets/year_month_picker.dart new file mode 100644 index 0000000..7542037 --- /dev/null +++ b/lib/views/accounting/widgets/year_month_picker.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; // 使用intl库来格式化日期 + +class YearMonthPicker extends StatefulWidget { + const YearMonthPicker({super.key}); + + @override + State createState() => _YearMonthPickerState(); +} + +class _YearMonthPickerState extends State { + String _selectedYearMonth = DateFormat('yyyy-MM').format(DateTime.now()); + + List> _buildYearMonthItems() { + List> items = []; + + // 假设你想要显示当前年份的前一年到后一年,每个月都作为一个选项 + for (int year = DateTime.now().year - 1; + year <= DateTime.now().year + 1; + year++) { + for (int month = 1; month <= 12; month++) { + String yearMonth = + DateFormat('yyyy-MM').format(DateTime(year, month, 1)); + items.add( + DropdownMenuItem( + value: yearMonth, + child: Text(yearMonth), + ), + ); + } + } + + return items; + } + + @override + Widget build(BuildContext context) { + return DropdownButton( + value: _selectedYearMonth, + items: _buildYearMonthItems(), + onChanged: (String? newValue) { + setState(() { + _selectedYearMonth = newValue!; + // 你可以在这里添加逻辑来处理选中的年月,比如解析为DateTime对象等 + }); + }, + hint: const Text('选择年月'), + ); + } +} diff --git a/lib/views/agi_llm_sample/aliyun_qwenvl_screen.dart b/lib/views/agi_llm_sample/aliyun_qwenvl_screen.dart new file mode 100644 index 0000000..83bdaff --- /dev/null +++ b/lib/views/agi_llm_sample/aliyun_qwenvl_screen.dart @@ -0,0 +1,643 @@ +// ignore_for_file: avoid_print, + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:intl/intl.dart'; +import 'package:uuid/uuid.dart'; + +import '../../apis/aliyun_apis.dart'; +import '../../common/components/tool_widget.dart'; +import '../../common/constants.dart'; +import '../../common/db_tools/db_helper.dart'; +import '../../models/ai_interface_state/aliyun_qwenvl_state.dart'; +import '../../models/common_llm_info.dart'; +import '../../models/llm_chat_state.dart'; +import 'widgets/message_item.dart'; + +/// +/// 2024-06-21 +/// 理论上这个对话,用户可以上传多个图片(即每次提问都带不同的图片) +/// 但目前留存历史记录是整个对话只存了一张图片,所以暂时不修改了。 +/// 这个页面也一样,先上传一张图片,然后后续对话都是基于这张图片的, +/// 即手动为用户提问的message的content带上image地址 +/// +class AliyunQwenVLScreen extends StatefulWidget { + const AliyunQwenVLScreen({super.key}); + + @override + State createState() => _AliyunQwenVLScreenState(); +} + +class _AliyunQwenVLScreenState extends State { + final DBHelper _dbHelper = DBHelper(); + final ScrollController _scrollController = ScrollController(); + + // 用户输入的文本控制器 + final TextEditingController _userInputController = TextEditingController(); + // 用户输入的内容(当不是AI在思考、且输入框有非空文字时才可以点击发送按钮) + String userInput = ""; + + // AI是否在思考中(如果是,则不允许再次发送) + bool isBotThinking = false; + +// 用于存储选中的图片文件地址 + String? _selectedImageUrl; + + // 假设的对话数据 + List messages = []; + + // 当前的对话记录(用于存入数据库或者从数据库中查询某个历史对话) + ChatSession? chatSession; + + // 最近对话需要的记录历史对话的变量 + List chatHsitory = []; + + // 等待AI响应时的占位的消息,在构建真实对话的list时要删除 + var placeholderMessage = ChatMessage( + messageId: "placeholderMessage", + text: "努力思考中 ", + isFromUser: false, + dateTime: DateTime.now(), + isPlaceholder: true, + ); + + // 选择图片来源 + Future _pickImage(ImageSource source) async { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: source); + if (pickedFile != null) { + setState(() { + _selectedImageUrl = pickedFile.path; + }); + } + } + + /// 给对话列表添加对话信息 + sendMessage(String text, {bool isFromUser = true, QwenVLUsage? usage}) { + setState(() { + // 发送消息的逻辑,这里只是简单地将消息添加到列表中 + int input = (usage?.inputTokens ?? 0) + (usage?.imageTokens ?? 0); + int output = usage?.outputTokens ?? 0; + messages.add(ChatMessage( + messageId: const Uuid().v4(), + text: text, + isFromUser: isFromUser, + dateTime: DateTime.now(), + inputTokens: input, + outputTokens: output, + totalTokens: input + output, + )); + + // AI思考和用户输入是相反的(如果用户输入了,就是在等到机器回答了) + isBotThinking = isFromUser; + + // 注意,在每次添加了对话之后,都把整个对话列表存入对话历史中去 + // 当然,要在占位消息之前 + _saveImage2TextChatToDb(); + + // 清空用户输入内容 + _userInputController.clear(); + + // 滚动到ListView的底部 + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 300), + ); + + // 如果是用户发送了消息,则开始等到AI响应(如果不是用户提问,则不会去调用接口) + if (isFromUser) { + // 如果是用户输入时,在列表中添加一个占位的消息,以便思考时的装圈和已加载的消息可以放到同一个list进行滑动 + // 一定注意要记得AI响应后要删除此占位的消息 + placeholderMessage.dateTime = DateTime.now(); + messages.add(placeholderMessage); + + // 用户发送之后,等待AI响应 + getAliyunQwenVLData(); + } + }); + } + + // 保存对话信息到数据库 + _saveImage2TextChatToDb() async { + // 如果插入时只有一条,那就是用户首次输入,截取部分内容和生成对话记录的uuid + if (messages.isNotEmpty && messages.length == 1) { + // 如果没有对话记录(即上层没有传入,且当前时用户第一次输入文字还没有创建对话记录),则新建对话记录 + chatSession ??= ChatSession( + uuid: const Uuid().v4(), + // 存完整的第一个问题,就不让修改了 + title: messages.first.text, + gmtCreate: DateTime.now(), + messages: messages, + // 2026-06-06 这里记录的也是各平台原始的大模型名称 + llmName: i2tLlmModels[Image2TextLLM.baiduFuyu8B]!, + cloudPlatformName: CloudPlatform.baidu.name, + // 2026-06-06 对话历史默认带上类别 + chatType: "image2text", + // 存base64显示时会一闪一闪,直接存缓存地址好了 + i2tImagePath: _selectedImageUrl, + ); + + await _dbHelper.insertChatList([chatSession!]); + } else if (messages.length > 1) { + // 如果已经有多个对话了,理论上该对话已经存入db了,只需要修改该对话的实际对话内容即可 + chatSession!.messages = messages; + await _dbHelper.updateChatSession(chatSession!); + } + + // 其他没有对话记录、没有消息列表的情况,就不做任何处理了 + } + + /// 获取图像理解返回的数据 + getAliyunQwenVLData() async { + print("-_selectedImageUrl:$_selectedImageUrl"); + + var url = + "https://dashscope.oss-cn-beijing.aliyuncs.com/images/dog_and_girl.jpeg"; + var base64 = base64Encode((await File(_selectedImageUrl!).readAsBytes())); + + print(url); + print(base64.length); + + // 经过测试,这个image参数只能是网络图片,本地图片不行,也不是示例中api的3个桌面平台 + + // 将已有的消息处理成Ernie支出的消息列表格式(构建查询条件时要删除占位的消息) + List msgs = messages + .where((e) => e.isPlaceholder != true) + .map((e) => QwenVLMessage( + role: e.isFromUser ? "user" : "assistant", + content: [ + QwenVLContent( + image: url, + text: e.text, + ) + ], + )) + .toList(); + + var temp = await getAliyunQwenVLResp( + msgs, + model: 'qwen-vl-plus', + stream: false, + isUserConfig: false, + ); + + // 得到回复后要删除表示加载中的占位消息 + setState(() { + messages.removeWhere((e) => e.isPlaceholder == true); + }); + + // 得到AI回复之后,添加到列表中,也注明不是用户提问 + var tempText = temp.map((e) => e.customReplyText).join(); + if (temp.isNotEmpty && temp.first.errorCode != null) { + tempText = """接口报错: +\ncode:${temp.first.errorCode} +\nmsg:${temp.first.errorMsg} +\n请检查AppId和AppKey是否正确,或切换其他模型试试。 +"""; + } + // 每次对话的结果流式返回,所以是个列表,就需要累加起来 + int inputTokens = 0; + int outputTokens = 0; + int imageTokens = 0; + for (var e in temp) { + inputTokens += e.usage?.inputTokens ?? 0; + outputTokens += e.usage?.outputTokens ?? 0; + imageTokens += e.usage?.imageTokens ?? 0; + } + // 里面的promptTokens和completionTokens是百度这个特立独行的,在上面拼到一起了 + var a = QwenVLUsage( + inputTokens: inputTokens, + outputTokens: outputTokens, + imageTokens: imageTokens, + ); + + print("限量测试的返回结果-------temp--$a"); + sendMessage(tempText, isFromUser: false, usage: a); + } + + /// 点击了最近对话的指定某条,则要查询对应信息 + getChatInfo(String chatId) async { + var list = await _dbHelper.queryChatList( + uuid: chatId, + cateType: "image2text", + ); + + if (list.isNotEmpty && list.isNotEmpty) { + setState(() { + // 注意:图像理解的前提是要有图像,如果记录中没有存图像,则不予显示对话 + if (list.first.i2tImagePath != null) { + _selectedImageUrl = list.first.i2tImagePath!; + chatSession = list.first; + + // 查到了db中的历史记录,则需要替换成当前的(父页面没选择历史对话进来就是空,则都不会有这个函数) + messages = chatSession!.messages; + } else { + EasyLoading.showError( + "该记录中未存在图像信息,\n故记录无效不予显示,请删除。", + duration: const Duration(seconds: 10), + ); + } + }); + } + } + + /// 最后一条大模型回复如果不满意,可以重新生成(中间的不行,因为后续的问题是关联上下文的) + regenerateLatestQuestion() { + var temp = messages.where((e) => !e.isFromUser).toList(); + + if (temp.isNotEmpty) { + setState(() { + // 将最后一条消息删除,并添加占位消息,重新发送 + messages.removeLast(); + placeholderMessage.dateTime = DateTime.now(); + messages.add(placeholderMessage); + + getAliyunQwenVLData(); + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + '图像理解(通义千问-VL)', + style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold), + ), + actions: [ + Builder( + builder: (BuildContext context) { + return IconButton( + // icon: Text( + // '最近对话', + // style: TextStyle( + // fontSize: 12.sp, + // color: Theme.of(context).primaryColor, + // ), + // ), + icon: Icon(Icons.history, size: 24.sp), + onPressed: () async { + // 获取历史记录 + var a = await _dbHelper.queryChatList(cateType: "image2text"); + setState(() { + chatHsitory = a; + }); + + if (!mounted) return; + // ignore: use_build_context_synchronously + Scaffold.of(context).openEndDrawer(); + }, + ); + }, + ), + ], + ), + body: GestureDetector( + // 允许子控件(如TextField)接收点击事件 + behavior: HitTestBehavior.translucent, + onTap: () { + // 点击空白处可以移除焦点,关闭键盘 + FocusScope.of(context).unfocus(); + }, + child: Column( + children: [ + SizedBox(height: 200.sp, child: buildImageViewAndUserInputArea()), + const Divider(), + Expanded(child: buildChatListArea()), + ], + ), + ), + endDrawer: Drawer( + child: ListView( + children: [ + SizedBox( + // 调整DrawerHeader的高度 + height: 60.sp, + child: DrawerHeader( + decoration: BoxDecoration(color: Colors.lightGreen[100]), + child: const Center(child: Text('最近图像理解记录')), + ), + ), + ...(chatHsitory.map((e) => buildGestureItems(e)).toList()), + ], + ), + ), + ); + } + + /// 构建图片预览和用户输入区域 + buildImageViewAndUserInputArea() { + return Row( + children: [ + SizedBox(width: 5.sp), + Expanded( + flex: 5, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // 图片预览 + SizedBox( + height: 150.sp, + child: buildImageView( + _selectedImageUrl, + context, + isFileUrl: true, + ), + ), + // 图片选择按钮 + Row( + children: [ + Expanded( + flex: 2, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(horizontal: 5.sp), + ), + onPressed: () { + FocusScope.of(context).unfocus(); + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text( + "选择图片", + style: TextStyle(fontSize: 20.sp), + ), + content: const Text("注意,仅支持单张图片!"), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _pickImage(ImageSource.camera); + }, + child: const Text("拍照"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _pickImage(ImageSource.gallery); + }, + child: const Text("相册"), + ), + ], + ); + }, + ); + }, + child: const Text('选择图片'), + ), + ), + Expanded( + flex: 1, + child: IconButton( + onPressed: () { + commonHintDialog(context, "说明", "点击图片可放大预览"); + }, + icon: Icon(Icons.help, size: 15.sp), + iconSize: 15.sp, + ), + ), + ], + ) + ], + ), + ), + SizedBox(width: 10.sp), + Expanded( + flex: 8, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _userInputController, + style: TextStyle(fontSize: 12.sp), + decoration: const InputDecoration( + hintText: '输入有关图片的任何问题\nAI对英语的理解效果更佳', + border: OutlineInputBorder(), // 添加边框 + ), + + // 外层有设定大小,可以设定膨胀为ture,且必须手动设定最大最小行为null + expands: true, + maxLines: null, + minLines: null, + onChanged: (String? text) { + if (text != null) { + setState(() { + userInput = text.trim(); + }); + } + }, + ), + ), + ElevatedButton( + // 如果没有选中图片,AI正在响应,或者输入框没有任何文字,不让点击发送 + onPressed: isBotThinking || + userInput.isEmpty || + _selectedImageUrl == null + ? null + : () { + // 在当前上下文中查找最近的 FocusScope 并使其失去焦点,从而收起键盘。 + FocusScope.of(context).unfocus(); + + // 用户发送消息 + sendMessage(userInput); + + // 发送完要清空记录用户输的入变量 + setState(() { + userInput = ""; + }); + }, + child: const Text( + "生成图像理解", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + SizedBox(width: 5.sp), + ], + ); + } + + /// 构建对话主体内容 + buildChatListArea() { + return ListView.builder( + controller: _scrollController, + itemCount: messages.length, + itemBuilder: (context, index) { + // 构建MessageItem + return Padding( + padding: EdgeInsets.all(5.sp), + child: Column( + children: [ + MessageItem(message: messages[index]), + // 如果是大模型回复,可以有一些功能按钮 + if (!messages[index].isFromUser) + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // 其中,是大模型最后一条回复,则可以重新生成 + // 注意,还要排除占位消息 + if ((index == messages.length - 1) && + messages[index].isPlaceholder != true) + TextButton( + onPressed: () { + regenerateLatestQuestion(); + }, + child: const Text("重新生成"), + ), + // 点击复制该条回复 + IconButton( + onPressed: () { + Clipboard.setData( + ClipboardData(text: messages[index].text), + ); + + EasyLoading.showToast( + "已复制到剪贴板", + duration: const Duration(seconds: 3), + toastPosition: EasyLoadingToastPosition.center, + ); + }, + icon: Icon(Icons.copy, size: 20.sp), + ), + // // 其他功能(占位) + IconButton( + onPressed: null, + icon: Icon(Icons.translate_outlined, size: 20.sp), + ), + // IconButton( + // onPressed: null, + // icon: Icon(Icons.thumb_down_outlined, size: 20.sp), + // ), + SizedBox(width: 10.sp), + ], + ) + ], + ), + ); + }, + ); + } + + /// 构建历史对话的条目 + buildGestureItems(ChatSession e) { + return GestureDetector( + onTap: () { + Navigator.of(context).pop(); + // 点击了指定的历史对话,则替换当前对话 + setState(() { + getChatInfo(e.uuid); + }); + }, + child: Card( + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.only(left: 5.sp), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + e.title, + style: TextStyle(fontSize: 12.sp), + maxLines: 2, + softWrap: true, + overflow: TextOverflow.ellipsis, + ), + Text( + DateFormat(constDatetimeFormat).format(e.gmtCreate), + style: TextStyle(fontSize: 10.sp), + ), + ], + ), + ), + ), + SizedBox( + width: 40.sp, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildDeleteBotton(e), + ], + ), + ), + ], + ), + ), + ); + } + + // 构建历史对话的删除按钮 + _buildDeleteBotton(ChatSession e) { + return SizedBox( + width: 40.sp, + child: IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text("确认删除图像理解记录:", style: TextStyle(fontSize: 18.sp)), + content: Text("记录请求编号:\n${e.uuid}"), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: const Text("取消"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: const Text("确定"), + ), + ], + ); + }, + ).then((value) async { + if (value == true) { + // 先删除 + await _dbHelper.deleteChatById(e.uuid); + + // 然后重新查询并更新 + var b = await _dbHelper.queryChatList(cateType: "image2text"); + setState(() { + chatHsitory = b; + }); + + if (chatSession?.uuid == e.uuid) { + setState(() { + chatSession = null; + messages.clear(); + }); + } + } + }); + }, + icon: Icon( + Icons.delete, + size: 16.sp, + color: Theme.of(context).primaryColor, + ), + iconSize: 18.sp, + padding: EdgeInsets.all(0.sp), + ), + ); + } +} diff --git a/lib/views/agi_llm_sample/aliyun_text2image_screen.dart b/lib/views/agi_llm_sample/aliyun_text2image_screen.dart new file mode 100644 index 0000000..cd4ef0a --- /dev/null +++ b/lib/views/agi_llm_sample/aliyun_text2image_screen.dart @@ -0,0 +1,874 @@ +// ignore_for_file: avoid_print + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:toggle_switch/toggle_switch.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../apis/aliyun_apis.dart'; +import '../../common/components/tool_widget.dart'; +import '../../common/db_tools/db_helper.dart'; +import '../../models/ai_interface_state/aliyun_text2image_state.dart'; +import '../../models/llm_text2image_state.dart'; +import '../accounting/mock_data/index.dart'; + +class AliyunText2ImageScreen extends StatefulWidget { + const AliyunText2ImageScreen({super.key}); + + @override + State createState() => _AliyunText2ImageScreenState(); +} + +class _AliyunText2ImageScreenState extends State + with WidgetsBindingObserver { + final DBHelper _dbHelper = DBHelper(); + + final _promptController = TextEditingController(); + final _negativePromptController = TextEditingController(); + + // 描述画面的提示词信息。支持中英文,长度不超过500个字符,超过部分会自动截断。 + String prompt = ""; + // 画面中不想出现的内容描述词信息。支持中英文,长度不超过500个字符,超过部分会自动截断。 + String negativePrompt = ""; + + // 可选的图片风格 + Map styles = { + "默认": 'auto', + "3D卡通": '3d cartoon', + "动画": 'anime', + "油画": 'oil painting', + "水彩": 'watercolor', + "素描": 'sketch', + "中国画": 'chinese painting', + "扁平插画": 'flat illustration', + }; + // 选定的风格对应的预览本地图片 + List styleImages = [ + 'assets/text2image_styles/默认.jpg', + 'assets/text2image_styles/3D卡通.jpg', + 'assets/text2image_styles/动画.jpg', + 'assets/text2image_styles/油画.jpg', + 'assets/text2image_styles/水彩.jpg', + 'assets/text2image_styles/素描.jpg', + 'assets/text2image_styles/中国画.jpg', + 'assets/text2image_styles/扁平插画.jpg', + ]; + +// 初始化为0,表示第一个样式被选中 + int _selectedStyleIndex = 0; + // 预设的尺寸列表 + final sizeList = ['1024*1024', '720*1280', '1280*720']; + // 被选中的尺寸索引 + int selectedSizeIndex = 0; + // 预设的张数列表 + final numSize = [1, 2, 3, 4]; + // 被选中生成的图片张数索引 + int selectedNumIndex = 0; + + // 是否正在生成图片 + bool isGenImage = false; + + // 最后生成的图片地址 + List rstImageUrls = []; + + // 添加一个overlay,在生成图片时,禁止用户的其他操作 + OverlayEntry? _overlayEntry; + + // 最近对话需要的记录历史对话的变量 + List text2ImageHsitory = []; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.detached || + state == AppLifecycleState.inactive) { + _removeLoadingOverlay(); + } + } + + /// 添加遮罩 + void _showLoadingOverlay() { + OverlayState? overlayState = Overlay.of(context); + _overlayEntry = OverlayEntry( + builder: (context) { + return Container( + width: double.infinity, + height: double.infinity, + color: Colors.black.withOpacity(0.8), + child: const Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + Text("图片生成中……"), + Text("请勿退出当前页面"), + ], + ), + ), + ); + }, + ); + overlayState.insert(_overlayEntry!); + } + + /// 移除遮罩 + void _removeLoadingOverlay() { + _overlayEntry?.remove(); + setState(() { + _overlayEntry = null; + }); + } + + /// 获取文生图的数据 + getText2ImageData() async { + // 如果在生成中,就不要继续生成了 + if (isGenImage) { + return; + } + + setState(() { + isGenImage = true; + _showLoadingOverlay(); + }); + + // 查看现在读取的内容 + print("正向词 $prompt"); + print("消极词 $negativePrompt"); + print("样式 <${styles.values.toList()[_selectedStyleIndex]}>"); + print("尺寸 ${sizeList[selectedSizeIndex]}"); + print("张数 ${numSize[selectedNumIndex]}"); + + var input = Input( + prompt: prompt, + negativePrompt: negativePrompt, + ); + + var parameters = Parameters( + style: "<${styles.values.toList()[_selectedStyleIndex]}>", + size: sizeList[selectedSizeIndex], + n: numSize[selectedNumIndex], + ); + + // 提交生成任务 + var jobResp = await commitAliyunText2ImgJob(input, parameters); + + print("job报错:$jobResp"); + + // 构建文生图任务报错 + if (jobResp.code != null) { + setState(() { + isGenImage = false; + _removeLoadingOverlay(); + }); + return commonExceptionDialog( + // ignore: use_build_context_synchronously + context, + "发生异常", + "生成图片出错:${jobResp.message}\n可以检查一下应用ID和KEY是否正确。", + ); + } + + // 得到任务编号之后,查询状态 + var taskId = jobResp.output?.taskId; + + if (taskId != null) { + // 获取到任务编号之后,定时查看任务进行状态 + AliyunTextToImgResp? rst = await timedText2ImageJobStatus(taskId); + + // 查询文生图任务进度报错 + if (rst?.code != null) { + setState(() { + isGenImage = false; + _removeLoadingOverlay(); + }); + return commonExceptionDialog( + // ignore: use_build_context_synchronously + context, + "发生异常", + "查询文本生图任务进度报错:${jobResp.message}\n可以检查一下应用ID和KEY是否正确。", + ); + } + + // 任务处理完成之后,放到结果列表中显示 + var a = rst?.output?.results; + List imageUrls = []; + + if (a != null && a.isNotEmpty) { + for (var e in a) { + if (e.url != null) imageUrls.add(e.url!); + } + } + + // 将任务结果存入数据库中 + await _dbHelper.insertTextToImageResultList([ + TextToImageResult( + requestId: jobResp.requestId ?? "无", + prompt: prompt, + negativePrompt: negativePrompt, + style: "<${styles.values.toList()[_selectedStyleIndex]}>", + imageUrls: imageUrls, + gmtCreate: DateTime.now(), + ) + ]); + + // 移除遮罩 + setState(() { + rstImageUrls = imageUrls; + isGenImage = false; + _removeLoadingOverlay(); + }); + } else { + // ???没有任务编号,应该是哪里报错了,应该要处理!!! + setState(() { + isGenImage = false; + _removeLoadingOverlay(); + }); + } + } + + // 定时检查文生图任务的状态 + Future timedText2ImageJobStatus(String taskId) async { + // 是否超时了 + bool isMaxWaitTimeExceeded = false; + + const maxWaitDuration = Duration(minutes: 10); // 设置最大等待时间为10分钟 + + // 超时之后会报错,所以就会跳出下面的while循环 + Timer timer = Timer(maxWaitDuration, () { + print('Max wait time exceeded. Stopping requests.'); + // 在这里可以执行一些清理工作,比如取消其他请求 + // 10分钟还没有得到结果,取消遮罩,弹窗报错 + setState(() { + isGenImage = false; + _removeLoadingOverlay(); + }); + + EasyLoading.showError( + "生成图片超时,请稍候重试!", + duration: const Duration(seconds: 10), + ); + + // 超时了要修改超时标识,以便退出while循环 + isMaxWaitTimeExceeded = true; + + print('Job wait time exceeded. Terminated...'); + }); + + // 10分钟定时内,循环获取文生图任务的状态;超过10分钟则超时跳出循环 + bool isRequestSuccessful = false; + while (!isRequestSuccessful && !isMaxWaitTimeExceeded) { + try { + var result = await getAliyunText2ImgJobResult(taskId); + + var boolFlag = result.output?.taskStatus == "SUCCEEDED" || + result.output?.taskStatus == "FAILED"; + + if (boolFlag) { + isRequestSuccessful = true; + print('Request successful!'); + timer.cancel(); // 请求成功,取消定时器 + + return result; + } else { + print('Job still running. Retrying...'); + // 如果请求失败,等待一段时间后重试 + await Future.delayed(const Duration(seconds: 5)); // 这里设置重试间隔为5秒 + } + } catch (e) { + // 处理其他可能的异常 + print('An error occurred: $e'); + await Future.delayed(const Duration(seconds: 5)); // 发生异常时也等待一段时间再重试 + } + } + return null; + } + + /// 节约请求资源,测试就模拟加载图片 + mockGetUrl() async { + print('mockGetUrl $isGenImage'); + + // 如果在生成中,就不要继续生成了 + if (isGenImage) { + return; + } + + setState(() { + isGenImage = true; + _showLoadingOverlay(); + }); + + // 模拟网络请求延迟 + await Future.delayed(const Duration(seconds: 1)); + + setState(() { + // 过期时间可能是一天 + rstImageUrls = aliyunText2ImageUrls; + isGenImage = false; + _removeLoadingOverlay(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + '文本生图(通义万相)', + style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold), + ), + actions: [ + Builder( + builder: (BuildContext context) { + return IconButton( + icon: Icon(Icons.history, size: 24.sp), + onPressed: () async { + // 获取历史记录 + var a = await _dbHelper.queryTextToImageResultList(); + + setState(() { + text2ImageHsitory = a; + }); + + if (!mounted) return; + // ignore: use_build_context_synchronously + Scaffold.of(context).openEndDrawer(); + }, + ); + }, + ), + ], + ), + resizeToAvoidBottomInset: false, + body: GestureDetector( + // 允许子控件(如TextField)接收点击事件 + behavior: HitTestBehavior.translucent, + onTap: () { + // 点击空白处可以移除焦点,关闭键盘 + FocusScope.of(context).unfocus(); + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + /// 执行按钮 + Padding( + padding: EdgeInsets.all(5.sp), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "文生图配置", + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.bold, + ), + ), + buildText2ImageButtonArea(), + ], + ), + ), + + /// 文生图配置折叠栏 + Expanded(flex: 2, child: buildConfigArea()), + + const Divider(), + Padding( + padding: EdgeInsets.all(5.sp), + child: Text( + "生成的图片(点击查看、长按保存)", + style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold), + ), + ), + + /// 文生图的结果 + buildImageResult(), + SizedBox(height: 10.sp), + ], + ), + ), + endDrawer: Drawer( + child: ListView( + children: [ + SizedBox( + // 调整DrawerHeader的高度 + height: 60.sp, + child: DrawerHeader( + decoration: BoxDecoration(color: Colors.lightGreen[100]), + child: const Center(child: Text('文本生成图片记录')), + ), + ), + ...(text2ImageHsitory.map((e) => buildGestureItems(e)).toList()), + ], + ), + ), + ); + } + + Future _launchUrl(String url) async { + if (!await launchUrl(Uri.parse(url))) { + throw Exception('Could not launch $url'); + } + } + + /// 构建在对话历史中的对话标题列表 + buildGestureItems(TextToImageResult e) { + return GestureDetector( + onTap: () { + Navigator.of(context).pop(); + + // 点击了指定文生图记录,弹窗显示缩略图 + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text("文本生成图片信息", style: TextStyle(fontSize: 18.sp)), + content: SizedBox( + height: 300.sp, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "正向提示词:", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text( + e.prompt, + style: TextStyle(fontSize: 12.sp), + ), + const Text( + "反向提示词:", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text( + e.negativePrompt ?? "无", + style: TextStyle(fontSize: 12.sp), + ), + Divider(height: 5.sp), + + /// 点击按钮去浏览器下载查看 + Wrap( + children: List.generate( + e.imageUrls?.length ?? 0, + (index) => ElevatedButton( + // 假设url一定存在的 + onPressed: () => _launchUrl(e.imageUrls![index]), + child: Text('图片${index + 1}'), + ), + ).toList(), + ), + + /// 图片预览,点击可放大,长按保存到相册 + /// 2024-06-27 ??? 为什么Z60U上不行?? + if (e.imageUrls != null && e.imageUrls!.isNotEmpty) + Wrap( + children: buildImageList( + e.style, + e.imageUrls!, + context, + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: const Text("确定"), + ), + ], + ); + }, + ); + }, + child: Card( + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.only(left: 5.sp), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "${e.requestId.substring(0, 6)} ${e.prompt.length > 10 ? e.prompt.substring(0, 10) : e.prompt}", + style: TextStyle(fontSize: 15.sp), + maxLines: 2, + softWrap: true, + overflow: TextOverflow.ellipsis, + ), + Text( + "创建时间:${e.gmtCreate} \n过期时间:${e.gmtCreate.add(const Duration(days: 1))}", + style: TextStyle(fontSize: 10.sp), + ), + ], + ), + ), + ), + _buildDeleteBotton(e), + ], + ), + ), + ); + } + + _buildDeleteBotton(TextToImageResult e) { + return SizedBox( + width: 40.sp, + child: IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text("确认删除文生图记录:", style: TextStyle(fontSize: 18.sp)), + content: Text("记录请求编号:\n${e.requestId}"), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: const Text("取消"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: const Text("确定"), + ), + ], + ); + }, + ).then((value) async { + if (value == true) { + // 先删除 + await _dbHelper.deleteTextToImageResultById(e.requestId); + + // 然后重新查询并更新 + var b = await _dbHelper.queryTextToImageResultList(); + setState(() { + text2ImageHsitory = b; + }); + } + }); + }, + icon: Icon( + Icons.delete, + size: 16.sp, + color: Theme.of(context).primaryColor, + ), + iconSize: 18.sp, + padding: EdgeInsets.all(0.sp), + ), + ); + } + + /// 构建文生图配置和执行按钮 + buildText2ImageButtonArea() { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () { + FocusScope.of(context).unfocus(); + // 处理编辑按钮的点击事件 + setState(() { + prompt = ""; + negativePrompt = ""; + _promptController.text = ""; + _negativePromptController.text = ""; + _selectedStyleIndex = 0; + selectedSizeIndex = 0; + selectedNumIndex = 0; + }); + }, + child: const Text("还原配置"), + ), + ElevatedButton( + onPressed: prompt.isNotEmpty + ? () async { + FocusScope.of(context).unfocus(); + + // 实际请求 + await getText2ImageData(); + + // 模拟请求 + // await mockGetUrl(); + } + : null, + child: const Text( + "生成图片", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ); + } + + /// 构建文生图的配置折叠栏 + buildConfigArea() { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + /// 画风、尺寸、张数选择 + _buildStyleAndNumberSizeArea(), + + /// 正向提示词 + _buildPromptHint(), + + /// 消极提示词 + _buildNegativePromptHint(), + ], + ), + ); + } + + /// 构建生成的图片区域 + buildImageResult() { + return SizedBox( + // 最多4张图片,每张占0.24宽度,高度就预留0.5宽度。在外层Column最下面留点空即可 + height: 0.5.sw, + child: SingleChildScrollView( + child: Column( + children: [ + if (rstImageUrls.isNotEmpty) + Padding( + padding: EdgeInsets.symmetric(horizontal: 0.25.sw), + child: buildNetworkImageViewGrid( + styles.keys.toList()[_selectedStyleIndex], + rstImageUrls, + context, + ), + ), + ], + ), + ), + ); + } + + /// 构建画风、尺寸、张数选择区域 + _buildStyleAndNumberSizeArea() { + return Row( + children: [ + SizedBox(width: 5.sp), + SizedBox(width: 0.48.sw, child: _buildImageGrid()), + SizedBox(width: 5.sp), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("尺寸:"), + Center( + child: ToggleSwitch( + minHeight: 32.sp, + minWidth: 0.47.sw, + fontSize: 12.sp, + cornerRadius: 5.sp, + dividerMargin: 10.sp, + initialLabelIndex: selectedSizeIndex, + totalSwitches: 3, + isVertical: true, + labels: sizeList, + // radiusStyle: true, + onToggle: (index) { + setState(() { + selectedSizeIndex = index ?? 0; + }); + }, + ), + ), + SizedBox(height: 2.sp), + const Text("张数(2毛一张):"), + Center( + child: ToggleSwitch( + minHeight: 32.sp, + minWidth: 0.115.sw, + fontSize: 12.sp, + cornerRadius: 5.sp, + dividerMargin: 0.sp, + initialLabelIndex: selectedNumIndex, + totalSwitches: 4, + radiusStyle: true, + multiLineText: true, + centerText: true, + labels: numSize.map((e) => "$e张").toList(), + onToggle: (index) { + setState(() { + selectedNumIndex = index ?? 0; + }); + }, + ), + ), + ], + ), + ], + ); + } + + /// 正向提示词输入框 + _buildPromptHint() { + return Padding( + padding: EdgeInsets.all(5.sp), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("正向提示词(不可为空)", style: TextStyle(color: Colors.green)), + TextField( + controller: _promptController, + decoration: InputDecoration( + hintText: '描述画面的提示词信息。支持中英文,不超过500个字符。\n比如:“一只展翅翱翔的狸花猫”', + hintStyle: TextStyle(fontSize: 12.sp), + border: const OutlineInputBorder(), // 添加边框 + ), + maxLines: 5, + minLines: 3, + onChanged: (String? text) { + if (text != null) { + setState(() { + prompt = text.trim(); + }); + } + }, + ), + ], + ), + ); + } + + /// 反向提示词输入框 + _buildNegativePromptHint() { + return Padding( + padding: EdgeInsets.all(5.sp), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("反向提示词(可以不填)"), + TextField( + controller: _negativePromptController, + decoration: InputDecoration( + hintText: + '画面中不想出现的内容描述词信息。通过指定用户不想看到的内容来优化模型输出,使模型产生更有针对性和理想的结果。', + hintStyle: TextStyle(fontSize: 12.sp), + border: const OutlineInputBorder(), // 添加边框 + ), + maxLines: 5, + minLines: 3, + onChanged: (String? text) { + if (text != null) { + setState(() { + negativePrompt = text.trim(); + }); + } + }, + ), + ], + ), + ); + } + + /// 预设的8种文生图的画风 + _buildImageGrid() { + return GridView.count( + crossAxisCount: 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: List.generate(8, (index) { + return GridTile( + child: GestureDetector( + onTap: () { + // 切换选中状态 + setState(() { + _selectedStyleIndex = _selectedStyleIndex == index ? -1 : index; + }); + }, + child: Container( + decoration: BoxDecoration( + // 选中的边框为蓝色,没选中则为透明 + border: Border.all( + color: _selectedStyleIndex == index + ? Colors.blue + : Colors.transparent, + width: 3.sp, + ), + borderRadius: BorderRadius.circular(5.0), // 可选,为图片添加圆角 + ), + child: _buildImageStack( + styleImages[index], + styles.keys.toList()[index], + styles.values.toList()[index], + ), + ), + ), + ); + }), + ); + } +} + +/// 构建图片上显示文本的组件 +_buildImageStack(String url, String label, String label1) { + return Stack( + fit: StackFit.expand, // 使Stack填满父Widget的空间 + children: [ + // 图片作为背景 + Positioned.fill( + child: Image.asset( + url, // 请替换为你的图片路径 + fit: BoxFit.cover, // 使图片覆盖并保持宽高比填充Stack + errorBuilder: + (BuildContext context, Object exception, StackTrace? stackTrace) { + // 图片加载失败时的回退处理 + return Container(color: Colors.grey.shade300); + }, + ), + ), + // 文字覆盖在图片上并居中 + Align( + alignment: Alignment.bottomCenter, // 这里使文字靠底居中 + child: RichText( + softWrap: true, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 3, + text: TextSpan( + children: [ + TextSpan( + text: label, + style: TextStyle( + // color: Colors.orange, + fontSize: 10.sp, + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: "\n$label1", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 9.sp), + ), + ], + ), + ), + ), + ], + ); +} diff --git a/lib/views/agi_llm_sample/baidu_image2text_screen.dart b/lib/views/agi_llm_sample/baidu_image2text_screen.dart new file mode 100644 index 0000000..17c767b --- /dev/null +++ b/lib/views/agi_llm_sample/baidu_image2text_screen.dart @@ -0,0 +1,592 @@ +// ignore_for_file: avoid_print + +import 'dart:convert'; +import 'dart:io'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:intl/intl.dart'; +import 'package:uuid/uuid.dart'; + +import '../../apis/baidu_apis.dart'; +import '../../common/components/tool_widget.dart'; +import '../../common/constants.dart'; +import '../../common/db_tools/db_helper.dart'; +import '../../common/utils/tools.dart'; +import '../../models/common_llm_info.dart'; +import '../../models/llm_chat_state.dart'; +import 'widgets/message_item.dart'; + +class BaiduImage2TextScreen extends StatefulWidget { + const BaiduImage2TextScreen({super.key}); + + @override + State createState() => _BaiduImage2TextScreenState(); +} + +class _BaiduImage2TextScreenState extends State { + final DBHelper _dbHelper = DBHelper(); + final ScrollController _scrollController = ScrollController(); + + // 用户输入的文本控制器 + final TextEditingController _userInputController = TextEditingController(); + // 用户输入的内容(当不是AI在思考、且输入框有非空文字时才可以点击发送按钮) + String userInput = ""; + + // AI是否在思考中(如果是,则不允许再次发送) + bool isBotThinking = false; + +// 用于存储选中的图片文件 + File? _selectedImage; + + // 假设的对话数据 + List messages = []; + + // 当前的对话记录(用于存入数据库或者从数据库中查询某个历史对话) + ChatSession? chatSession; + + // 最近对话需要的记录历史对话的变量 + List chatHsitory = []; + + // 等待AI响应时的占位的消息,在构建真实对话的list时要删除 + var placeholderMessage = ChatMessage( + messageId: "placeholderMessage", + text: "努力思考中 ", + isFromUser: false, + dateTime: DateTime.now(), + isPlaceholder: true, + ); + + // 选择图片来源 + Future _pickImage(ImageSource source) async { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: source); + if (pickedFile != null) { + setState(() { + _selectedImage = File(pickedFile.path); + }); + } + } + + // 用户选择了图片,会获取图片的信息 + userPickImage() async { + if (!(await requestPermission())) { + return EasyLoading.showError("未授权可访问图片,无法选择图片"); + } + try { + // 选择新图片,就是新开图像理解对话了 + setState(() { + chatSession = null; + messages.clear(); + }); + + final result = await FilePicker.platform.pickFiles( + type: FileType.image, // 选择图片类型 + ); + + if (result != null) { + // 获取文件,创建File对象 + final file = File(result.files.single.path!); + + print("选中的文件的地址${file.path}"); + + setState(() { + // 将图片文件赋值给全局变量 + _selectedImage = file; + }); + } + } on PlatformException catch (e) { + print("Unsupported operation$e"); + } + } + + /// 给对话列表添加对话信息 + sendMessage(String text, {bool isFromUser = true}) { + setState(() { + // 发送消息的逻辑,这里只是简单地将消息添加到列表中 + messages.add(ChatMessage( + messageId: const Uuid().v4(), + text: text, + isFromUser: isFromUser, + dateTime: DateTime.now(), + )); + + // AI思考和用户输入是相反的(如果用户输入了,就是在等到机器回答了) + isBotThinking = isFromUser; + + // 注意,在每次添加了对话之后,都把整个对话列表存入对话历史中去 + // 当然,要在占位消息之前 + _saveImage2TextChatToDb(); + + // 清空用户输入内容 + _userInputController.clear(); + + // 滚动到ListView的底部 + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 300), + ); + + // 如果是用户发送了消息,则开始等到AI响应(如果不是用户提问,则不会去调用接口) + if (isFromUser) { + // 如果是用户输入时,在列表中添加一个占位的消息,以便思考时的装圈和已加载的消息可以放到同一个list进行滑动 + // 一定注意要记得AI响应后要删除此占位的消息 + placeholderMessage.dateTime = DateTime.now(); + messages.add(placeholderMessage); + + // 用户发送之后,等待AI响应 + getFuyuData(text); + } + }); + } + + // 保存对话信息到数据库 + _saveImage2TextChatToDb() async { + // 如果插入时只有一条,那就是用户首次输入,截取部分内容和生成对话记录的uuid + if (messages.isNotEmpty && messages.length == 1) { + // 如果没有对话记录(即上层没有传入,且当前时用户第一次输入文字还没有创建对话记录),则新建对话记录 + chatSession ??= ChatSession( + uuid: const Uuid().v4(), + // 存完整的第一个问题,就不让修改了 + title: messages.first.text, + gmtCreate: DateTime.now(), + messages: messages, + // 2026-06-06 这里记录的也是各平台原始的大模型名称 + llmName: i2tLlmModels[Image2TextLLM.baiduFuyu8B]!, + cloudPlatformName: CloudPlatform.baidu.name, + // 2026-06-06 对话历史默认带上类别 + chatType: "image2text", + // 存base64显示时会一闪一闪,直接存缓存地址好了 + i2tImagePath: _selectedImage?.path, + ); + + await _dbHelper.insertChatList([chatSession!]); + } else if (messages.length > 1) { + // 如果已经有多个对话了,理论上该对话已经存入db了,只需要修改该对话的实际对话内容即可 + chatSession!.messages = messages; + await _dbHelper.updateChatSession(chatSession!); + } + + // 其他没有对话记录、没有消息列表的情况,就不做任何处理了 + } + + /// 获取图像理解返回的数据 + getFuyuData(String text) async { + var a = await getBaiduFuyu8BResp( + text, + base64Encode((await _selectedImage!.readAsBytes())), + ); + + // 得到回复后要删除表示加载中的占位消息 + setState(() { + messages.removeWhere((e) => e.isPlaceholder == true); + }); + + var tempText = a.result ?? "未报错但无返回数据"; + if (a.errorCode != null) { + tempText = """接口报错: +\ncode:${a.errorCode} +\nmsg:${a.errorMsg} +\n请检查输入是否正确,或切换其他模型试试。 +"""; + } + + // ??? 错误理解之类的还没处理 + sendMessage(tempText, isFromUser: false); + } + + /// 点击了最近对话的指定某条,则要查询对应信息 + getChatInfo(String chatId) async { + var list = await _dbHelper.queryChatList( + uuid: chatId, + cateType: "image2text", + ); + + if (list.isNotEmpty && list.isNotEmpty) { + setState(() { + // 注意:图像理解的前提是要有图像,如果记录中没有存图像,则不予显示对话 + if (list.first.i2tImagePath != null) { + _selectedImage = File(list.first.i2tImagePath!); + chatSession = list.first; + + // 查到了db中的历史记录,则需要替换成当前的(父页面没选择历史对话进来就是空,则都不会有这个函数) + messages = chatSession!.messages; + } else { + EasyLoading.showError( + "该记录中未存在图像信息,\n故记录无效不予显示,请删除。", + duration: const Duration(seconds: 10), + ); + } + }); + } + } + + /// 最后一条大模型回复如果不满意,可以重新生成(中间的不行,因为后续的问题是关联上下文的) + regenerateLatestQuestion() { + var temp = messages.where((e) => !e.isFromUser).toList(); + + if (temp.isNotEmpty) { + setState(() { + // 将最后一条消息删除,并添加占位消息,重新发送 + messages.removeLast(); + placeholderMessage.dateTime = DateTime.now(); + messages.add(placeholderMessage); + + getFuyuData(temp.last.text); + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + '图像理解(Fuyu-8B)', + style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold), + ), + actions: [ + Builder( + builder: (BuildContext context) { + return IconButton( + // icon: Text( + // '最近对话', + // style: TextStyle( + // fontSize: 12.sp, + // color: Theme.of(context).primaryColor, + // ), + // ), + icon: Icon(Icons.history, size: 24.sp), + onPressed: () async { + // 获取历史记录 + var a = await _dbHelper.queryChatList(cateType: "image2text"); + setState(() { + chatHsitory = a; + }); + + if (!mounted) return; + // ignore: use_build_context_synchronously + Scaffold.of(context).openEndDrawer(); + }, + ); + }, + ), + ], + ), + body: GestureDetector( + // 允许子控件(如TextField)接收点击事件 + behavior: HitTestBehavior.translucent, + onTap: () { + // 点击空白处可以移除焦点,关闭键盘 + FocusScope.of(context).unfocus(); + }, + child: Column( + children: [ + SizedBox(height: 200.sp, child: buildImageViewAndUserInputArea()), + const Divider(), + Expanded(child: buildChatListArea()), + ], + ), + ), + endDrawer: Drawer( + child: ListView( + children: [ + SizedBox( + // 调整DrawerHeader的高度 + height: 60.sp, + child: DrawerHeader( + decoration: BoxDecoration(color: Colors.lightGreen[100]), + child: const Center(child: Text('最近图像理解记录')), + ), + ), + ...(chatHsitory.map((e) => buildGestureItems(e)).toList()), + ], + ), + ), + ); + } + + /// 构建图片预览和用户输入区域 + buildImageViewAndUserInputArea() { + return Row( + children: [ + SizedBox(width: 5.sp), + Expanded( + flex: 5, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // 图片预览 + SizedBox( + height: 150.sp, + child: buildImageView(_selectedImage, context), + ), + // 图片选择按钮 + Row( + children: [ + Expanded( + flex: 2, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(horizontal: 5.sp), + ), + // onPressed: userPickImage, + onPressed: () { + _pickImage(ImageSource.gallery); + }, + child: const Text('选择图片'), + ), + ), + Expanded( + flex: 1, + child: IconButton( + onPressed: () { + commonHintDialog( + context, + "说明", + "点击图片可预览,支持jpg/png/bmp\n要求base64编码后大小不超过4M;\n最短边至少15px,最长边最大4096px.", + ); + }, + icon: Icon(Icons.help, size: 15.sp), + iconSize: 15.sp, + ), + ), + ], + ) + ], + ), + ), + SizedBox(width: 10.sp), + Expanded( + flex: 8, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _userInputController, + style: TextStyle(fontSize: 12.sp), + decoration: const InputDecoration( + hintText: '输入有关图片的任何问题\nAI对英语的理解效果更佳', + border: OutlineInputBorder(), // 添加边框 + ), + + // 外层有设定大小,可以设定膨胀为ture,且必须手动设定最大最小行为null + expands: true, + maxLines: null, + minLines: null, + onChanged: (String? text) { + if (text != null) { + setState(() { + userInput = text.trim(); + }); + } + }, + ), + ), + ElevatedButton( + // 如果没有选中图片,AI正在响应,或者输入框没有任何文字,不让点击发送 + onPressed: + isBotThinking || userInput.isEmpty || _selectedImage == null + ? null + : () { + // 在当前上下文中查找最近的 FocusScope 并使其失去焦点,从而收起键盘。 + FocusScope.of(context).unfocus(); + + // 用户发送消息 + sendMessage(userInput); + + // 发送完要清空记录用户输的入变量 + setState(() { + userInput = ""; + }); + }, + child: const Text( + "生成图像理解", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + SizedBox(width: 5.sp), + ], + ); + } + + /// 构建对话主体内容 + buildChatListArea() { + return ListView.builder( + controller: _scrollController, + itemCount: messages.length, + itemBuilder: (context, index) { + // 构建MessageItem + return Padding( + padding: EdgeInsets.all(5.sp), + child: Column( + children: [ + MessageItem(message: messages[index]), + // 如果是大模型回复,可以有一些功能按钮 + if (!messages[index].isFromUser) + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // 其中,是大模型最后一条回复,则可以重新生成 + // 注意,还要排除占位消息 + if ((index == messages.length - 1) && + messages[index].isPlaceholder != true) + TextButton( + onPressed: () { + regenerateLatestQuestion(); + }, + child: const Text("重新生成"), + ), + // 点击复制该条回复 + IconButton( + onPressed: () { + Clipboard.setData( + ClipboardData(text: messages[index].text), + ); + + EasyLoading.showToast( + "已复制到剪贴板", + duration: const Duration(seconds: 3), + toastPosition: EasyLoadingToastPosition.center, + ); + }, + icon: Icon(Icons.copy, size: 20.sp), + ), + // // 其他功能(占位) + IconButton( + onPressed: null, + icon: Icon(Icons.translate_outlined, size: 20.sp), + ), + // IconButton( + // onPressed: null, + // icon: Icon(Icons.thumb_down_outlined, size: 20.sp), + // ), + SizedBox(width: 10.sp), + ], + ) + ], + ), + ); + }, + ); + } + + /// 构建历史对话的条目 + buildGestureItems(ChatSession e) { + return GestureDetector( + onTap: () { + Navigator.of(context).pop(); + // 点击了指定的历史对话,则替换当前对话 + setState(() { + getChatInfo(e.uuid); + }); + }, + child: Card( + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.only(left: 5.sp), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + e.title, + style: TextStyle(fontSize: 12.sp), + maxLines: 2, + softWrap: true, + overflow: TextOverflow.ellipsis, + ), + Text( + DateFormat(constDatetimeFormat).format(e.gmtCreate), + style: TextStyle(fontSize: 10.sp), + ), + ], + ), + ), + ), + SizedBox( + width: 40.sp, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildDeleteBotton(e), + ], + ), + ), + ], + ), + ), + ); + } + + // 构建历史对话的删除按钮 + _buildDeleteBotton(ChatSession e) { + return SizedBox( + width: 40.sp, + child: IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text("确认删除图像理解记录:", style: TextStyle(fontSize: 18.sp)), + content: Text("记录请求编号:\n${e.uuid}"), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: const Text("取消"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: const Text("确定"), + ), + ], + ); + }, + ).then((value) async { + if (value == true) { + // 先删除 + await _dbHelper.deleteChatById(e.uuid); + + // 然后重新查询并更新 + var b = await _dbHelper.queryChatList(cateType: "image2text"); + setState(() { + chatHsitory = b; + }); + + if (chatSession?.uuid == e.uuid) { + setState(() { + chatSession = null; + messages.clear(); + }); + } + } + }); + }, + icon: Icon( + Icons.delete, + size: 16.sp, + color: Theme.of(context).primaryColor, + ), + iconSize: 18.sp, + padding: EdgeInsets.all(0.sp), + ), + ); + } +} diff --git a/lib/views/agi_llm_sample/cus_llm_config/user_cus_model_stepper.dart b/lib/views/agi_llm_sample/cus_llm_config/user_cus_model_stepper.dart new file mode 100644 index 0000000..a42aaa3 --- /dev/null +++ b/lib/views/agi_llm_sample/cus_llm_config/user_cus_model_stepper.dart @@ -0,0 +1,372 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; + +import '../../../common/components/tool_widget.dart'; +import '../../../common/utils/tools.dart'; +import '../../../models/common_llm_info.dart'; +import '../../../services/cus_get_storage.dart'; +import '../one_chat_screen.dart'; + +/// +/// 用户自定义模型配置的平台、模型名、appid、appkey +/// +class UserCusModelStepper extends StatefulWidget { + const UserCusModelStepper({super.key}); + + @override + State createState() => _UserCusModelStepperState(); +} + +class _UserCusModelStepperState extends State { + int _index = 0; + CloudPlatform selectedPlatform = CloudPlatform.baidu; + PlatformLLM? selectedLLM; + + final _formKey = GlobalKey(); + + @override + initState() { + super.initState(); + + // 因为要在表单渲染成功之后再初始化值 + WidgetsBinding.instance.addPostFrameCallback((_) { + initUserCOnfigration(); + }); + } + +// 如果缓存中已经存在了用户的配置,直接读取出来显示 + initUserCOnfigration() { + var name = MyGetStorage().getCusLlmName(); + var pf = MyGetStorage().getCusPlatform(); + + if (name == null || pf == null) { + return; + } + // 找到还没超时的大模型,取第一个作为预设的 + setState(() { + // 找到对应的平台和模型(因为配置的时候是用户下拉选择的,理论上这里一定存在,且只应该有一个) + selectedPlatform = + CloudPlatform.values.where((e) => e.name == pf).toList().first; + + // 找到平台之后,也要找到对应选中的模型 + selectedLLM = PlatformLLM.values.where((m) => m.name == name).first; + + var map = getIdAndKeyFromPlatform(selectedPlatform); + + // 初始化id或者key + _formKey.currentState?.fields['id']?.didChange(map['id']); + _formKey.currentState?.fields['key']?.didChange(map['key']); + }); + } + + /// 当切换了云平台时,要同步切换选中的大模型 + onCloudPlatformChanged(CloudPlatform? value) { + // 如果平台被切换,则更新当前的平台为选中的平台,且重置模型为符合该平台的模型的第一个 + if (value != selectedPlatform) { + // 更新被选中的平台为当前选中平台 + selectedPlatform = value ?? CloudPlatform.baidu; + + // 找到符合平台的模型(???理论上一定不为空,为空了就是有问题的数据) + // 注意,免费的就不让配置了 + var temp = PlatformLLM.values + .where((e) => + e.name.startsWith(selectedPlatform.name) && + !e.name.endsWith("FREE")) + .toList(); + + setState(() { + selectedLLM = temp.first; + // 切换平台之后,如果有全局配置的应用id和key,也跟着切换 + var map = getIdAndKeyFromPlatform(selectedPlatform); + _formKey.currentState?.fields['id']?.didChange(map['id']); + _formKey.currentState?.fields['key']?.didChange(map['key']); + }); + } + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + children: [ + Container( + height: 100.sp, + color: Colors.lightBlueAccent, + child: Center( + child: Text("自行配置平台对话模型", style: TextStyle(fontSize: 20.sp)), + ), + ), + Stepper( + currentStep: _index, + onStepCancel: () { + if (_index > 0) { + setState(() { + _index -= 1; + }); + } + }, + onStepContinue: () { + if (_index <= 0) { + setState(() { + _index += 1; + }); + } + }, + onStepTapped: (int index) { + setState(() { + _index = index; + }); + }, + controlsBuilder: (BuildContext context, ControlsDetails details) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + // 当处于最后一个步骤时,执行自定义操作,否则继续下一步 + onPressed: (_index == getStepList().length - 1) + ? () async { + print("这是最后一步,就不会触发上面的onStepContinue定义了"); + + if (_formKey.currentState!.saveAndValidate()) { + if (selectedLLM?.name == null) { + return commonExceptionDialog( + context, "数据异常", "请选择平台和模型,不可为空"); + } + + await MyGetStorage() + .setCusPlatform(selectedPlatform.name); + await MyGetStorage() + .setCusLlmName(selectedLLM!.name); + + var temp = _formKey.currentState; + + await setIdAndKeyFromPlatform( + selectedPlatform, + temp?.fields['id']?.value, + temp?.fields['key']?.value, + ); + + if (!mounted) return; + Navigator.pushReplacement( + // ignore: use_build_context_synchronously + context, + MaterialPageRoute( + builder: (context) => const OneChatScreen( + isUserConfig: true, + ), + ), + ); + } else { + return commonExceptionDialog( + context, "数据异常", "请检查应用id和key是否输入"); + } + } + : details.onStepContinue, + + // 修改最后一个步骤的按钮文字为 "完成" + child: Text( + (_index == getStepList().length - 1) ? '完成' : '继续', + ), + ), + TextButton( + // 如果是第一步的取消,就返回上一页了(push过来的,pop过去就好) + onPressed: (_index == 0) + ? () { + Navigator.pop(context); + } + : details.onStepCancel, + child: Text((_index == 0) ? '取消' : '上一步'), + ), + ], + ); + }, + steps: getStepList(), + ) + ], + ), + ); + } + + List getStepList() { + return [ + Step( + state: (selectedLLM != null) ? StepState.complete : StepState.indexed, + isActive: _index >= 0, + title: const Text('选择平台和模型'), + content: Row( + children: [ + Expanded( + flex: 1, + // 下拉框有个边框,需要放在容器中 + child: Container( + alignment: Alignment.centerLeft, + child: SizedBox( + width: 100.sp, + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey, width: 1.0), + borderRadius: BorderRadius.circular(4), + ), + child: DropdownButton( + value: selectedPlatform, + isDense: true, + alignment: AlignmentDirectional.center, + items: CloudPlatform.values + .where((e) => !e.name.startsWith("limited")) + .map((e) { + return DropdownMenuItem( + value: e, + alignment: AlignmentDirectional.center, + child: Text( + e.name, + style: + TextStyle(fontSize: 12.sp, color: Colors.blue), + ), + ); + }).toList(), + onChanged: onCloudPlatformChanged, + ), + ), + ), + ), + ), + SizedBox(width: 10.sp), + Expanded( + flex: 2, + // 下拉框有个边框,需要放在容器中 + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey, width: 1.0), + borderRadius: BorderRadius.circular(4), + ), + child: DropdownButton( + value: selectedLLM, + isDense: true, + alignment: AlignmentDirectional.centerEnd, + items: PlatformLLM.values + .where((m) => + m.name.startsWith(selectedPlatform.name) && + !m.name.endsWith("FREE")) + .map((e) => DropdownMenuItem( + value: e, + alignment: AlignmentDirectional.center, + child: Text( + newLLMSpecs[e]!.name, + style: TextStyle( + fontSize: 11.sp, + color: Colors.blue, + ), + ), + )) + .toList(), + onChanged: (val) { + setState(() { + selectedLLM = val!; + }); + }, + ), + ), + ), + ], + ), + ), + Step( + state: _formKey.currentState?.saveAndValidate() != true + ? StepState.complete + : StepState.indexed, + isActive: _index >= 1, + title: const Text('输入平台应用ID和KEY'), + content: FormBuilder( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + FormBuilderTextField( + name: 'id', + decoration: const InputDecoration( + labelText: '应用ID', + // 设置透明底色 + filled: true, + fillColor: Colors.transparent, + ), + onChanged: (val) { + setState(() {}); + }, + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(), + ]), + // initialValue: '12', + // 2023-12-21 enableSuggestions 设为 true后键盘类型为text就正常了。 + // 2024-05-27 9.3.0 版本了还没修 + enableSuggestions: true, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.next, + ), + FormBuilderTextField( + name: 'key', + decoration: const InputDecoration( + labelText: '应用KEY', + // 设置透明底色 + filled: true, + fillColor: Colors.transparent, + ), + onChanged: (val) { + setState(() {}); + }, + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(), + ]), + // initialValue: '12', + // 2023-12-21 enableSuggestions 设为 true后键盘类型为text就正常了。 + // 2024-05-27 9.3.0 版本了还没修 + enableSuggestions: true, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.done, + ), + ], + ), + ), + ), + ]; + } +} + +// /// 如果是用户自行输入的话,不关心其他限制,就只看平台、模型参数即可 +// /// +// enum CusPlatform { baidu, tencent, aliyun } + +// class CusChatLLMSpec { +// // 模型字符串(平台API参数的那个model的值)、模型名称、上下文长度数值,到期时间、限量数值, +// final String name; +// final String model; +// // 3个平台开通服务都需要绑定应用,然后都需要提供key和id类似物,叫法可能不太一样 +// // 阿里是APP_ID、API_KEY;百度是API_KEY、SECRET_KEY;腾讯是SECRET_ID、SECRET_KEY +// // final String secretId; +// // final String secretKey; + +// CusChatLLMSpec(this.name, this.model); +// } + +// Map> supportedCusLLMs = { +// CusPlatform.baidu: [ +// CusChatLLMSpec("ErnieLite8K", "ernie_speed"), +// CusChatLLMSpec("ErnieSpeed128K", "ernie-speed-128k"), +// CusChatLLMSpec("ErnieLite8K", "ernie-lite-8k"), +// CusChatLLMSpec("ErnieTiny8K", "ernie-tiny-8k"), +// ], +// CusPlatform.aliyun: [ +// CusChatLLMSpec("QwenTurbo", "qwen-turbo"), +// CusChatLLMSpec("QwenPlus", "qwen-plus"), +// CusChatLLMSpec("QwenLong", "qwen-long"), +// CusChatLLMSpec("QwenMax", "qwen-max"), +// CusChatLLMSpec("QwenMaxLongContext", "qwen-max-longcontext"), +// ], +// CusPlatform.tencent: [ +// CusChatLLMSpec("HunyuanLite", "hunyuan-lite"), +// ], +// }; diff --git a/lib/views/agi_llm_sample/index.dart b/lib/views/agi_llm_sample/index.dart new file mode 100644 index 0000000..8916ff1 --- /dev/null +++ b/lib/views/agi_llm_sample/index.dart @@ -0,0 +1,557 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; + +import '../../common/components/tool_widget.dart'; +import '../../common/utils/tools.dart'; +import '../../models/common_llm_info.dart'; +import '../../services/cus_get_storage.dart'; +import 'aliyun_qwenvl_screen.dart'; +import 'baidu_image2text_screen.dart'; +import 'aliyun_text2image_screen.dart'; +import 'cus_llm_config/user_cus_model_stepper.dart'; +import 'one_chat_screen.dart'; + +class AgiLlmSample extends StatefulWidget { + const AgiLlmSample({super.key}); + + @override + State createState() => _AgiLlmSampleState(); +} + +class _AgiLlmSampleState extends State { + // 表单的全局key + final GlobalKey _formKey = GlobalKey(); + + // 是否用户有配置通用平台的appid和key + bool isBaiduConfigured = false; + bool isAliyunConfigured = false; + bool isTencentConfigured = false; + + // 不明说的东西:长按“智能助手”这标题,使用我自己的三个平台的应用id和key + bool isAuthorsAppInfo = false; + + String note = """暂仅使用百度千帆、阿里百炼、腾讯混元3个平台。 + 以免费和测试为目的,支持使用的大模型数量有限。 + +--- + +**文本对话(官方免费)** +文本翻译、百科问答、情感分析、FAQ、阅读理解、内容创作、代码编写……。 + +---*以下需要配置对应平台自己的应用ID和KEY*--- + +**文本对话(单个配置)** +使用更加高级的对话模型,需要用户自己的ID和KEY。 +**文本生图** *(阿里百炼:通义万相)* +简单的几句话,就能帮你生成各种风格的图片。 +**图像理解** *(百度千帆第三方:Fuyu-8B)* +给它一张图,它能回答你关于该图片的相关问题。 + +--- + +**点击**下面指定功能,快来试一试吧!"""; + + @override + initState() { + super.initState(); + checkPlatformConfig(); + } + + checkPlatformConfig() { + // 是否使用作者的应用配置 + setState(() { + isAuthorsAppInfo = MyGetStorage().getIsAuthorsAppInfo(); + }); + + if (MyGetStorage().getBaiduCommonAppId() != null && + MyGetStorage().getBaiduCommonAppKey() != null) { + setState(() { + isBaiduConfigured = true; + }); + } else { + setState(() { + isBaiduConfigured = false; + }); + } + + if (MyGetStorage().getAliyunCommonAppId() != null && + MyGetStorage().getAliyunCommonAppKey() != null) { + setState(() { + isAliyunConfigured = true; + }); + } else { + setState(() { + isAliyunConfigured = false; + }); + } + + if (MyGetStorage().getTencentCommonAppId() != null && + MyGetStorage().getTencentCommonAppKey() != null) { + setState(() { + isTencentConfigured = true; + }); + } else { + setState(() { + isTencentConfigured = false; + }); + } + + print( + "检查后的bat配置与否:$isBaiduConfigured $isAliyunConfigured $isTencentConfigured"); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: GestureDetector( + onLongPress: () async { + // 长按之后,先改变是否使用作者应用的标志 + setState(() { + isAuthorsAppInfo = !isAuthorsAppInfo; + }); + + // 改变后是true,则需要使用作者的平台应用配置 + if (isAuthorsAppInfo) { + await setDefaultAppIdAndKey(); + + if (!mounted) return; + // ignore: use_build_context_synchronously + showSnackMessage(context, "你将使用作者的应用ID和KEY,请谨慎!"); + } else { + // 不然就是清除使用作者的平台应用配置 + await clearAllAppIdAndKey(); + + if (!mounted) return; + // ignore: use_build_context_synchronously + showSnackMessage(context, "你将不再使用作者的平台应用配置,感谢!"); + } + + // 配置完成,需要检查当前的应用配置,并切换是否使用为相反的值 + await MyGetStorage().setIsAuthorsAppInfo(isAuthorsAppInfo); + setState(() { + checkPlatformConfig(); + }); + }, + child: RichText( + softWrap: true, + overflow: TextOverflow.ellipsis, + maxLines: 2, + text: TextSpan( + children: [ + // 为了分类占的宽度一致才用的,只是显示的话可不必 + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: 50.sp), + child: const Text('智能助手'), + ), + ), + // TextSpan( + // text: " (Simple AGI LLMs)", + // style: TextStyle(color: Colors.black, fontSize: 15.sp), + // ), + ], + ), + ), + ), + // title: const Text('智能对话'), + actions: [ + TextButton( + // 如果在缓存中存在配置,则跳到到对话页面,如果没有,进入配置页面 + onPressed: () { + buildUserAppInfoDialog(); + }, + child: const Text("应用配置"), + ), + TextButton( + // 如果在缓存中存在配置,则跳到到对话页面,如果没有,进入配置页面 + onPressed: () async { + // 清除配置,且不管如何都重置是否使用作者配置为false + await clearAllAppIdAndKey(); + MyGetStorage().setIsAuthorsAppInfo(false); + + setState(() { + checkPlatformConfig(); + }); + + if (!mounted) return; + // ignore: use_build_context_synchronously + commonHintDialog(context, "清除配置", "平台应用配置已全部清除"); + }, + child: const Text("清除配置"), + ), + // TextButton( + // // 如果在缓存中存在配置,则跳到到对话页面,如果没有,进入配置页面 + // onPressed: () { + // Navigator.push( + // context, + // MaterialPageRoute( + // builder: (context) => const UserCusModelStepper(), + // ), + // ); + // }, + // child: const Text("自行配置"), + // ), + ], + ), + body: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // 显示说明 + Expanded( + child: Align( + alignment: Alignment.bottomCenter, + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(10.sp), + child: MarkdownBody(data: note), + ), + ), + ), + ), + Divider(height: 50.sp), + // 入口按钮 + SizedBox( + height: 0.32.sh, + child: GridView.count( + primary: false, + padding: EdgeInsets.symmetric(horizontal: 20.sp), + crossAxisSpacing: 20, + mainAxisSpacing: 20, + crossAxisCount: 2, + childAspectRatio: 5 / 2, + children: [ + buildAIToolEntrance(0, "文本对话", "通用—官方免费", + color: Colors.blue[100]), + // 2024-06-24 如果使用作者的平台应用配置,那可以使用作者的限量测试的api + isAuthorsAppInfo + ? buildAIToolEntrance(1, "文本对话", "阿里—限量测试", + color: Colors.blue[100]) + : buildAIToolEntrance(5, "文本对话", "通用—单个配置", + color: Colors.blue[100]), + // Container(), + buildAIToolEntrance(2, "文本生图", "阿里—通义万相", + color: Colors.grey[100]), + buildAIToolEntrance(3, "图像理解", "百度—Fuyu-8B", + color: Colors.green[100]), + // 2024-06-24 这个API调用不符合文档的设定??? + // buildAIToolEntrance(4, "视觉模型", "阿里—通义千问", + // color: Colors.green[100]), + ], + ), + ), + ], + ), + ); + } + + /// 构建用户自己的应用信息 + buildUserAppInfoDialog() { + return showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text("平台应用配置", style: TextStyle(fontSize: 18.sp)), + content: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(0.sp), + child: FormBuilder( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + FormBuilderDropdown( + name: 'platform', + validator: FormBuilderValidators.compose( + [FormBuilderValidators.required()]), + decoration: const InputDecoration( + labelText: '平台', + // 设置透明底色 + filled: true, + fillColor: Colors.transparent, + // 输入框添加边框 + border: OutlineInputBorder( + // 设置边框圆角 + borderRadius: BorderRadius.all(Radius.circular(10.0)), + // 设置边框颜色和宽度 + borderSide: + BorderSide(color: Colors.blue, width: 2.0), + ), + ), + items: CloudPlatform.values + .where((e) => !e.name.startsWith("limited")) + .map((platform) => DropdownMenuItem( + alignment: AlignmentDirectional.center, + value: platform, + child: Text(platform.name), + )) + .toList(), + onChanged: (val) { + // 找到还没超时的大模型,取第一个作为预设的 + setState(() { + // 找到对应的平台和模型(因为配置的时候是用户下拉选择的,理论上这里一定存在,且只应该有一个) + + if (val == CloudPlatform.baidu) { + // 初始化id或者key + _formKey.currentState?.fields['id']?.didChange( + MyGetStorage().getBaiduCommonAppId()); + _formKey.currentState?.fields['key']?.didChange( + MyGetStorage().getBaiduCommonAppKey()); + } + if (val == CloudPlatform.aliyun) { + // 初始化id或者key + _formKey.currentState?.fields['id']?.didChange( + MyGetStorage().getAliyunCommonAppId()); + _formKey.currentState?.fields['key']?.didChange( + MyGetStorage().getAliyunCommonAppKey()); + } + if (val == CloudPlatform.tencent) { + // 初始化id或者key + _formKey.currentState?.fields['id']?.didChange( + MyGetStorage().getTencentCommonAppId()); + _formKey.currentState?.fields['key']?.didChange( + MyGetStorage().getTencentCommonAppKey()); + } + }); + }, + valueTransformer: (val) => val?.toString(), + ), + SizedBox(height: 10.sp), + FormBuilderTextField( + name: 'id', + decoration: const InputDecoration( + labelText: '应用ID', + // 设置透明底色 + filled: true, + fillColor: Colors.transparent, + // 输入框添加边框 + border: OutlineInputBorder( + // 设置边框圆角 + borderRadius: BorderRadius.all(Radius.circular(10.0)), + // 设置边框颜色和宽度 + borderSide: + BorderSide(color: Colors.blue, width: 2.0), + ), + ), + onChanged: (val) { + setState(() { + print("输入的id---$val"); + }); + }, + enableSuggestions: true, + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(), + ]), + // textInputAction: TextInputAction.next, + ), + SizedBox(height: 10.sp), + FormBuilderTextField( + name: 'key', + decoration: const InputDecoration( + labelText: '应用KEY', + // 设置透明底色 + filled: true, + fillColor: Colors.transparent, + // 输入框添加边框 + border: OutlineInputBorder( + // 设置边框圆角 + borderRadius: BorderRadius.all(Radius.circular(10.0)), + // 设置边框颜色和宽度 + borderSide: + BorderSide(color: Colors.blue, width: 2.0), + ), + ), + onChanged: (val) { + setState(() { + print("输入的key---$val"); + }); + }, + enableSuggestions: true, + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(), + ]), + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: const Text("取消"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: const Text("确定"), + ), + ], + ); + }, + ).then((value) async { + if (value == true) { + if (_formKey.currentState!.saveAndValidate()) { + var temp = _formKey.currentState; + + CloudPlatform cp = temp?.fields['platform']?.value; + String id = temp?.fields['id']?.value; + String key = temp?.fields['key']?.value; + + await setIdAndKeyFromPlatform(cp, id, key); + + print("cp---------------$cp"); + + setState(() { + checkPlatformConfig(); + }); + } + } + }); + } + + /// 构建AI对话云平台入口按钮 + buildAIToolEntrance(int type, String label, String subtitle, {Color? color}) { + return InkWell( + onTap: () { + // 0, "智能对话-免费" + // 1, "智能对话-限量 + // 2, "文本生图 + // 3, "图像理解 + // 4, "千问视觉 + // 5, 自行配置单个付费对话模型 + if (type == 0) { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const OneChatScreen()), + ); + } else if (type == 1) { + if (isAliyunConfigured) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const OneChatScreen(isLimitedTest: true), + ), + ); + } else { + commonHintDialog(context, "配置错误", "未配置阿里云平台的应用ID和KEY"); + } + } else if (type == 2) { + if (isAliyunConfigured) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const AliyunText2ImageScreen(), + ), + ); + } else { + commonHintDialog(context, "配置错误", "未配置阿里云平台的应用ID和KEY"); + } + } else if (type == 3) { + if (isBaiduConfigured) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const BaiduImage2TextScreen(), + ), + ); + } else { + commonHintDialog(context, "配置错误", "未配置百度云平台的应用ID和KEY"); + } + } else if (type == 4) { + if (isAliyunConfigured) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const AliyunQwenVLScreen(), + ), + ); + } else { + commonHintDialog(context, "配置错误", "未配置阿里云平台的应用ID和KEY"); + } + } else if (type == 5) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const UserCusModelStepper(), + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const OneChatScreen()), + ); + } + }, + child: Container( + padding: EdgeInsets.all(8.sp), + decoration: BoxDecoration( + // 设置圆角半径为10 + borderRadius: BorderRadius.all(Radius.circular(15.sp)), + color: color ?? Colors.teal[200], + // 添加阴影效果 + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), // 阴影颜色 + spreadRadius: 2, // 阴影的大小 + blurRadius: 5, // 阴影的模糊程度 + offset: Offset(0, 2.sp), // 阴影的偏移量 + ), + ], + ), + child: Center( + child: RichText( + softWrap: true, + overflow: TextOverflow.ellipsis, + maxLines: 2, + textAlign: TextAlign.center, + text: TextSpan( + children: [ + // 为了分类占的宽度一致才用的,只是显示的话可不必 + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: 50.sp), + child: Text( + label, + style: TextStyle( + fontSize: 18.sp, + color: Colors.blueAccent, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + TextSpan( + text: "\n$subtitle", + style: TextStyle(color: Colors.black, fontSize: 12.sp), + ), + ], + ), + ), + // Text( + // label, + // style: TextStyle( + // fontSize: 15.sp, + // fontWeight: FontWeight.bold, + // color: Theme.of(context).primaryColor, + // ), + // ), + ), + ), + ); + } +} diff --git a/lib/views/agi_llm_sample/one_chat_screen.dart b/lib/views/agi_llm_sample/one_chat_screen.dart new file mode 100644 index 0000000..5fc9a34 --- /dev/null +++ b/lib/views/agi_llm_sample/one_chat_screen.dart @@ -0,0 +1,1202 @@ +// ignore_for_file: avoid_print, + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:intl/intl.dart'; +import 'package:toggle_switch/toggle_switch.dart'; +import 'package:uuid/uuid.dart'; + +import '../../apis/common_chat_apis.dart'; +import '../../common/components/tool_widget.dart'; +import '../../common/constants.dart'; +import '../../common/db_tools/db_helper.dart'; +import '../../common/utils/tools.dart'; +import '../../models/common_llm_info.dart'; +import '../../models/ai_interface_state/platform_aigc_commom_state.dart'; +import '../../models/llm_chat_state.dart'; + +import '../../services/cus_get_storage.dart'; +import 'cus_llm_config/user_cus_model_stepper.dart'; +import 'widgets/message_item.dart'; + +/// 2024-06-20 +/// 现在主要有3个进入对话聊天页面的地方: +/// 1是预设的使用我的appid和key的默认的文生文,此时使用预设的官方免费的模型 +/// 2是预设的使用我的appid和key的【限时限量】的文生文,此时使用limited开头的那些模型 +/// 3是用户自行配置的appid和key,此时使用少数几个付费的模型(不是limited开始也不是FREE结尾的模型) +/// 但其他对话的内容,包括展示、保存等,其实是一样的 +/// +class OneChatScreen extends StatefulWidget { + // 默认只展示FREE结尾的免费模型,且不用用户配置 + + // 理论上不会两者同时传true的(因为我没法简单知道用户配置的限时限量是多少) + // 是否是用户自行配置;如果是,展示非limited开始和FREE结尾的模型 + final bool? isUserConfig; + // 是否是显示限量测试;如果是,就不用展示平台、只展示limited开始的模型 + final bool? isLimitedTest; + const OneChatScreen({super.key, this.isUserConfig, this.isLimitedTest}); + + @override + State createState() => _OneChatScreenState(); +} + +class _OneChatScreenState extends State { + final DBHelper _dbHelper = DBHelper(); + + // 人机对话消息滚动列表 + final ScrollController _scrollController = ScrollController(); + + // 用户输入的文本控制器 + final TextEditingController _userInputController = TextEditingController(); + // 用户输入的内容(当不是AI在思考、且输入框有非空文字时才可以点击发送按钮) + String userInput = ""; + + // 要修改某个对话的名称 + final TextEditingController _titleController = TextEditingController(); + + // 要修改最近对话列表中指定的某个对话的名称 + final _selectedTitleController = TextEditingController(); + + /// 级联选择效果:云平台-模型名 + /// 2024-06-15 这里限量的,暂时都是阿里云平台的,但单独取名limited??? + /// 也没有其他可修改的地方 + CloudPlatform selectedPlatform = CloudPlatform.limited; + PlatformLLM selectedLlm = PlatformLLM.limitedYiLarge; + + // AI是否在思考中(如果是,则不允许再次发送) + bool isBotThinking = false; + + /// 2024-06-11 默认使用流式请求,更快;但是同样的问题,流式使用的token会比非流式更多 + /// 2024-06-15 限时限量的可能都是收费的,本来就慢,所以默认就流式,不用切换 + /// 2024-06-20 流式使用的token太多了,还是默认更省的 + bool isStream = false; + + // 默认进入对话页面应该就是啥都没有,然后根据这空来显示预设对话 + List messages = []; + + // 2024-06-01 当前的对话记录(用于存入数据库或者从数据库中查询某个历史对话) + ChatSession? chatSession; + + // 最近对话需要的记录历史对话的变量 + List chatHsitory = []; + + // 等待AI响应时的占位的消息,在构建真实对话的list时要删除 + var placeholderMessage = ChatMessage( + messageId: "placeholderMessage", + text: "努力思考中(等待越久,回复内容越多) ", + isFromUser: false, + dateTime: DateTime.now(), + isPlaceholder: true, + ); + + // 进入对话页面简单预设的一些问题 + List defaultQuestions = defaultChatQuestions; + + @override + void initState() { + super.initState(); + + initCusConfig(); + } + + // 进入自行配置的对话页面,看看用户配置有没有生效 + initCusConfig() { + print("11111111"); + + // 如果是用户自行配置页面来的 + if (widget.isUserConfig == true) { + var pf = MyGetStorage().getCusPlatform(); + var name = MyGetStorage().getCusLlmName(); + + // 找到还没超时的大模型,取第一个作为预设的 + setState(() { + // 找到对应的平台和模型(因为配置的时候是用户下拉选择的,理论上这里一定存在,且只应该有一个) + selectedPlatform = + CloudPlatform.values.where((e) => e.name == pf).toList().first; + + // 找到平台之后,也要找到对应选中的模型 + selectedLlm = PlatformLLM.values.where((m) => m.name == name).first; + }); + } else if (widget.isLimitedTest == true) { + // 找到还没超时的限时限量的大模型,取第一个作为预设的 + setState(() { + selectedPlatform = CloudPlatform.limited; + + selectedLlm = PlatformLLM.values + .where((m) => + m.name.startsWith(selectedPlatform.name) && + newLLMSpecs[m]!.deadline.isAfter(DateTime.now())) + .first; + }); + } else { + // 找到免费的大模型,取第一个作为预设的 + selectedPlatform = CloudPlatform.baidu; + setState(() { + selectedLlm = PlatformLLM.values + .where((m) => + m.name.startsWith(selectedPlatform.name) && + m.name.endsWith("FREE")) + .first; + }); + } + + print("配置选中后的平台和模型"); + print("$selectedPlatform $selectedLlm"); + print("${widget.isUserConfig} ${widget.isLimitedTest}"); + } + + //获取指定分类的历史对话 + Future> getHsitoryChats() async { + // 获取历史记录:默认查询到所有的历史对话,再根据条件过滤 + var list = await _dbHelper.queryChatList(cateType: "aigc"); + + // 如果是限量的,平台只能时limited(模型也只能是limited的,应该不用判断也是) + if (widget.isLimitedTest == true) { + list = list + .where((e) => e.cloudPlatformName == CloudPlatform.limited.name) + .toList(); + } else if (widget.isUserConfig == true) { + // 如果是用户配置的,平台非limited,模型非是FREE结尾 + list = list + .where((e) => + e.cloudPlatformName != CloudPlatform.limited.name && + !e.llmName.endsWith("FREE")) + .toList(); + } else { + // 默认就是免费的了,平台非limited,模型仅是FREE结尾 + list = list + .where((e) => + e.cloudPlatformName != CloudPlatform.limited.name && + e.llmName.endsWith("FREE")) + .toList(); + } + return list; + } + + /// 获取指定对话列表 + _getChatInfo(String chatId) async { + print("调用了getChatInfo----------"); + + // 2024-06-15 这里要过滤只是限量的部分 + // 2024-06-20 虽然所有对话都是用同一个页面,但是带出的历史对话可能会有继续沟通的需要 + // 此时用户可切换的平台和模型,就需要根据来源(预设、限量、用户配置)来加载了。 + // 那么历史对话如果不是这上面的模型,继续对话就会出问题 + // var list = (await _dbHelper.queryChatList(uuid: chatId, cateType: "aigc")) + // .where((e) => e.cloudPlatformName == selectedPlatform.name) + // .toList(); + // 默认查询到所有的历史对话(这里有uuid了,应该就只有1条存在才对) + var list = await _dbHelper.queryChatList(uuid: chatId, cateType: "aigc"); + + // 如果是限量的,平台只能时limited(模型也只能是limited的,应该不用判断也是) + if (widget.isLimitedTest == true) { + list = list + .where((e) => e.cloudPlatformName == CloudPlatform.limited.name) + .toList(); + } else if (widget.isUserConfig == true) { + // 如果是用户配置的,平台非limited,模型非是FREE结尾 + list = list + .where((e) => + e.cloudPlatformName != CloudPlatform.limited.name && + !e.llmName.endsWith("FREE")) + .toList(); + } else { + // 默认就是免费的了,平台非limited,模型仅是FREE结尾 + list = list + .where((e) => + e.cloudPlatformName != CloudPlatform.limited.name && + e.llmName.endsWith("FREE")) + .toList(); + } + + if (list.isNotEmpty && list.isNotEmpty) { + setState(() { + chatSession = list.first; + + // 如果有存是哪个模型,也默认选中该模型 + // ???2024-06-11 虽然同一个对话现在可以切换平台和模型了,但这里只是保留第一次对话取的值 + // 后面对话过程中切换平台和模型,只会在该次对话过程中有效 + var tempLlms = newLLMSpecs.entries + // 数据库存的模型名就是自定义的模型名 + .where((e) => e.key.name == list.first.llmName) + .toList(); + + // 被选中的平台也就是记录中存放的平台 + var tempCps = CloudPlatform.values + .where((e) => e.name.contains(list.first.cloudPlatformName ?? "")) + .toList(); + + // 避免麻烦,两个都不为空才显示;否则还是预设的 + if (tempLlms.isNotEmpty && tempCps.isNotEmpty) { + selectedLlm = tempLlms.first.key; + selectedPlatform = tempCps.first; + } + + // 查到了db中的历史记录,则需要替换成当前的(父页面没选择历史对话进来就是空,则都不会有这个函数) + messages = chatSession!.messages; + }); + } + } + + // 这个发送消息实际是将对话文本添加到对话列表中 + // 但是在用户发送消息之后,需要等到AI响应,成功响应之后将响应加入对话中 + _sendMessage(String text, {bool isFromUser = true, CommonUsage? usage}) { + // 发送消息的逻辑,这里只是简单地将消息添加到列表中 + var temp = ChatMessage( + messageId: const Uuid().v4(), + text: text, + isFromUser: isFromUser, + dateTime: DateTime.now(), + inputTokens: usage?.inputTokens, + outputTokens: usage?.outputTokens, + totalTokens: usage?.totalTokens, + ); + + setState(() { + // AI思考和用户输入是相反的(如果用户输入了,就是在等到机器回到了) + isBotThinking = isFromUser; + + messages.add(temp); + + // 2024-06-01 注意,在每次添加了对话之后,都把整个对话列表存入对话历史中去 + // 当然,要在占位消息之前 + _saveToDb(); + + _userInputController.clear(); + // 滚动到ListView的底部 + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 300), + ); + + // 如果是用户发送了消息,则开始等到AI响应(如果不是用户提问,则不会去调用接口) + if (isFromUser) { + // 如果是用户输入时,在列表中添加一个占位的消息,以便思考时的装圈和已加载的消息可以放到同一个list进行滑动 + // 一定注意要记得AI响应后要删除此占位的消息 + placeholderMessage.dateTime = DateTime.now(); + messages.add(placeholderMessage); + + // 不是腾讯,就是百度 + _getLlmResponse(); + } + }); + } + + // 保存对话到数据库 + _saveToDb() async { + print("处理插入前message的长度${messages.length}"); + // 如果插入时只有一条,那就是用户首次输入,截取部分内容和生成对话记录的uuid + + if (messages.isNotEmpty && messages.length == 1) { + // 如果没有对话记录(即上层没有传入,且当前时用户第一次输入文字还没有创建对话记录),则新建对话记录 + chatSession ??= ChatSession( + uuid: const Uuid().v4(), + title: messages.first.text.length > 30 + ? messages.first.text.substring(0, 30) + : messages.first.text, + gmtCreate: DateTime.now(), + messages: messages, + // 2026-06-20 这里记录的自定义模型枚举的值,因为后续查询结果过滤有需要用来判断 + llmName: selectedLlm.name, + cloudPlatformName: selectedPlatform.name, + // 2026-06-06 对话历史默认带上类别 + chatType: "aigc", + ); + + print("这是输入了第一天消息,生成了初始化的对话$chatSession"); + + print("进入了插入$chatSession"); + await _dbHelper.insertChatList([chatSession!]); + + // 如果已经有多个对话了,理论上该对话已经存入db了,只需要修改该对话的实际对话内容即可 + } else if (messages.length > 1) { + chatSession!.messages = messages; + + print("进入了修改----$chatSession"); + + await _dbHelper.updateChatSession(chatSession!); + } + + // 其他没有对话记录、没有消息列表的情况,就不做任何处理了 + + print("++++++++++++++++++++++++++++++"); + } + + // 根据不同的平台、选中的不同模型,调用对应的接口,得到回复 + // 虽然返回的响应通用了,但不同的平台和模型实际取值还是没有抽出来的 + _getLlmResponse() async { + // 将已有的消息处理成Ernie支出的消息列表格式(构建查询条件时要删除占位的消息) + List msgs = messages + .where((e) => e.isPlaceholder != true) + .map((e) => CommonMessage( + content: e.text, + role: e.isFromUser ? "user" : "assistant", + )) + .toList(); + + // 等待请求响应 + List temp; + // 2024-06-06 ??? 这里一定要确保存在模型名称,因为要作为http请求参数 + var model = newLLMSpecs[selectedLlm]!.model; + // 是用户配置,id和key就使用用户的,不然就是我的 + var isUserConfig = widget.isUserConfig == true ? true : false; + print("显示请求的模型名称!----$model"); + // 2024-06-11 如果是用户切换了“更快”或“更多”,则使用不同的请求 + if (selectedPlatform == CloudPlatform.baidu) { + temp = await getBaiduAigcResp(msgs, + model: model, stream: isStream, isUserConfig: isUserConfig); + } else if (selectedPlatform == CloudPlatform.tencent) { + temp = await getTencentAigcResp(msgs, + model: model, stream: isStream, isUserConfig: isUserConfig); + } else if (selectedPlatform == CloudPlatform.aliyun) { + temp = await getAliyunAigcResp(msgs, + model: model, stream: isStream, isUserConfig: isUserConfig); + } else if (selectedPlatform == CloudPlatform.limited) { + // 目前限时限量的,其实也只是阿里云平台的 + temp = await getAliyunAigcResp(msgs, + model: model, stream: isStream, isUserConfig: isUserConfig); + } else { + // 理论上不会存在其他的了 + temp = await getBaiduAigcResp(msgs, + model: model, stream: isStream, isUserConfig: isUserConfig); + } + + // 得到回复后要删除表示加载中的占位消息 + setState(() { + messages.removeWhere((e) => e.isPlaceholder == true); + }); + + // 得到AI回复之后,添加到列表中,也注明不是用户提问 + var tempText = temp.map((e) => e.customReplyText).join(); + if (temp.isNotEmpty && temp.first.errorCode != null) { + tempText = """接口报错: +\ncode:${temp.first.errorCode} +\nmsg:${temp.first.errorMsg} +\n请检查AppId和AppKey是否正确,或切换其他模型试试。 +"""; + } + + // 每次对话的结果流式返回,所以是个列表,就需要累加起来 + int inputTokens = 0; + int outputTokens = 0; + int totalTokens = 0; + for (var e in temp) { + inputTokens += e.usage?.inputTokens ?? e.usage?.promptTokens ?? 0; + outputTokens += e.usage?.outputTokens ?? e.usage?.completionTokens ?? 0; + totalTokens += e.usage?.totalTokens ?? 0; + } + // 里面的promptTokens和completionTokens是百度这个特立独行的,在上面拼到一起了 + var a = CommonUsage( + inputTokens: inputTokens, + outputTokens: outputTokens, + totalTokens: totalTokens, + ); + + print("限量测试的返回结果-------temp--$a"); + _sendMessage(tempText, isFromUser: false, usage: a); + } + + /// 2024-05-31 暂时不根据token的返回来说了,临时直接显示整个对话不超过8千字 + /// 限量的有放在对象里面 + bool isMessageTooLong() => + messages.fold(0, (sum, msg) => sum + msg.text.length) > + newLLMSpecs[selectedLlm]!.contextLength; + + /// 构建用于下拉的平台列表(根据上层传入的值) + List> buildCloudPlatforms() { + var cps = CloudPlatform.values; + + // 如果是限量的,平台只能是limited;其他的就是预设的其他3个平台 + if (widget.isLimitedTest == true) { + cps = cps.where((e) => e == CloudPlatform.limited).toList(); + } else { + cps = cps.where((e) => e != CloudPlatform.limited).toList(); + } + + print("cps-----$cps"); + + return cps.map((e) { + return DropdownMenuItem( + value: e, + alignment: AlignmentDirectional.center, + child: Text( + cpNames[e]!, + style: TextStyle(fontSize: 12.sp, color: Colors.blue), + ), + ); + }).toList(); + } + + /// 当切换了云平台时,要同步切换选中的大模型 + onCloudPlatformChanged(CloudPlatform? value) { + // 如果平台被切换,则更新当前的平台为选中的平台,且重置模型为符合该平台的模型的第一个 + if (value != selectedPlatform) { + // 更新被选中的平台为当前选中平台 + selectedPlatform = value ?? CloudPlatform.baidu; + + // 用于显示下拉的模型,也要根据入口来 + // 先找到符合平台的模型(???理论上一定不为空,为空了就是有问题的数据) + var temp = PlatformLLM.values + .where((e) => e.name.startsWith(selectedPlatform.name)) + .toList(); + + // 如果是限量的,平台只能时limited(模型也只能是limited的,应该不用判断也是) + if (widget.isLimitedTest == true) { + // 目前限时限量的模型只有名称是选中的limted开头的平台这一个限制,不用二次过滤 + } else if (widget.isUserConfig == true) { + // 如果是用户配置的,模型非是FREE结尾 + temp = temp.where((e) => !e.name.endsWith("FREE")).toList(); + } else { + // 默认就是免费的了,模型仅是FREE结尾 + temp = temp.where((e) => e.name.endsWith("FREE")).toList(); + } + + setState(() { + selectedLlm = temp.first; + }); + } + } + + List> buildPlatformLLMs() { + // 用于下拉的模型首先是需要以平台前缀命名的 + var llms = PlatformLLM.values + .where((m) => m.name.startsWith(selectedPlatform.name)); + + var text = (ChatLLMSpec e) => e.name; + + // 限时限量的模型, 以limited平台前缀开头的模型,且未过期 + if (widget.isLimitedTest == true) { + llms = llms + .where((m) => newLLMSpecs[m]!.deadline.isAfter(DateTime.now())) + .toList(); + + text = (ChatLLMSpec e) => + "${e.name}_${DateFormat(constDateFormat).format(e.deadline)}到期"; + } else if (widget.isUserConfig == true) { + // 如果是用户配置的,模型仅是指定平台前缀+以非FREE结尾 + llms = llms.where((m) => !m.name.endsWith("FREE")).toList(); + } else { + // 默认就是免费的了,模型仅是指定平台前缀+以FREE结尾 + llms = llms.where((m) => m.name.endsWith("FREE")).toList(); + } + + print("llms--${llms.length}---$llms"); + + return llms + .map((e) => DropdownMenuItem( + value: e, + alignment: widget.isLimitedTest == true + ? AlignmentDirectional.centerEnd + : AlignmentDirectional.center, + child: Text( + text(newLLMSpecs[e]!), + style: TextStyle(fontSize: 10.sp, color: Colors.blue), + ), + )) + .toList(); + } + + /// 最后一条大模型回复如果不满意,可以重新生成(中间的不行,因为后续的问题是关联上下文的) + /// 2024-06-20 限量的要计算token数量,所以不让重新生成(???但实际也没做累加的token的逻辑) + regenerateLatestQuestion() { + setState(() { + // 将最后一条消息删除,并添加占位消息,重新发送 + messages.removeLast(); + placeholderMessage.dateTime = DateTime.now(); + messages.add(placeholderMessage); + + _getLlmResponse(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: buildAppbarArea(), + body: GestureDetector( + // 允许子控件(如TextField)接收点击事件 + behavior: HitTestBehavior.translucent, + onTap: () { + // 点击空白处可以移除焦点,关闭键盘 + FocusScope.of(context).unfocus(); + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + /// 构建可切换云平台和模型的行 + Container( + color: Colors.grey[300], + child: Padding( + padding: EdgeInsets.only(left: 10.sp), + child: buildPlatAndLlmRow(), + ), + ), + + /// 如果对话是空,显示预设的问题 + if (messages.isEmpty) ...buildDefaultQuestionArea(), + + /// 在顶部显示对话标题(避免在appbar显示,内容太挤) + if (chatSession != null) buildChatTitleArea(), + + // 标题和对话正文的分割线 + const Divider(), + + /// 显示对话消息主体 + buildChatListArea(), + + /// 显示输入框和发送按钮 + const Divider(), + buildUserSendArea(), + ], + ), + ), + endDrawer: Drawer( + child: ListView( + children: [ + SizedBox( + // 调整DrawerHeader的高度 + height: 60.sp, + child: DrawerHeader( + decoration: BoxDecoration(color: Colors.lightGreen[100]), + child: const Center(child: Text('最近对话')), + ), + ), + ...(chatHsitory.map((e) => buildGestureItems(e)).toList()), + ], + ), + ), + ); + } + + /// 构建appbar区域 + buildAppbarArea() { + return AppBar( + title: Text( + '对话│${widget.isUserConfig == true ? '自定' : widget.isLimitedTest == true ? "限量" : "免费"}', + style: TextStyle(fontSize: 15.sp, fontWeight: FontWeight.bold), + ), + actions: [ + /// 选择“更快”就使用流式请求,否则就一般的非流式 + ToggleSwitch( + minHeight: 24.sp, + minWidth: 40.sp, + fontSize: 9.sp, + cornerRadius: 5.sp, + dividerMargin: 0.sp, + // isVertical: true, + // // 激活时按钮的前景背景色 + // activeFgColor: Colors.black, + // activeBgColor: [Colors.green], + // // 未激活时的前景背景色 + // inactiveBgColor: Colors.grey, + // inactiveFgColor: Colors.white, + initialLabelIndex: isStream ? 0 : 1, + totalSwitches: 2, + labels: const ['更快', '更省'], + // radiusStyle: true, + onToggle: (index) { + setState(() { + isStream = index == 0 ? true : false; + }); + }, + ), + SizedBox(width: 20.sp), + + /// 创建新对话 + IconButton( + onPressed: () { + // 建立新对话就是把已有的对话清空就好(因为保存什么的在发送消息时就处理了)??? + setState(() { + chatSession = null; + messages.clear(); + }); + }, + icon: Icon(Icons.add, size: 24.sp), + ), + Builder( + builder: (BuildContext context) { + return IconButton( + icon: Icon(Icons.history, size: 24.sp), + onPressed: () async { + // 获取历史记录:默认查询到所有的历史对话,再根据条件过滤 + var list = await getHsitoryChats(); + // 显示最近的对话 + + print("list--------$list"); + setState(() { + chatHsitory = list; + }); + + if (!mounted) return; + // ignore: use_build_context_synchronously + Scaffold.of(context).openEndDrawer(); + }, + ); + }, + ), + ], + ); + } + + /// 构建在对话历史中的对话标题列表 + buildGestureItems(ChatSession e) { + return GestureDetector( + onTap: () { + Navigator.of(context).pop(); + // 点击了知道历史对话,则替换当前对话 + setState(() { + _getChatInfo(e.uuid); + }); + }, + child: Card( + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.only(left: 5.sp), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + e.title, + style: TextStyle(fontSize: 12.sp), + maxLines: 2, + softWrap: true, + overflow: TextOverflow.ellipsis, + ), + Text( + DateFormat(constDatetimeFormat).format(e.gmtCreate), + style: TextStyle(fontSize: 10.sp), + ), + ], + ), + ), + ), + SizedBox( + width: 80.sp, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildUpdateBotton(e), + _buildDeleteBotton(e), + ], + ), + ), + ], + ), + ), + ); + } + + _buildDeleteBotton(ChatSession e) { + return SizedBox( + width: 40.sp, + child: IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text("确认删除对话记录:", style: TextStyle(fontSize: 18.sp)), + content: Text(e.title), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: const Text("取消"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: const Text("确定"), + ), + ], + ); + }, + ).then((value) async { + if (value == true) { + // 先删除 + await _dbHelper.deleteChatById(e.uuid); + + // 然后重新查询并更新 + var list = await getHsitoryChats(); + + setState(() { + chatHsitory = list; + }); + + // 2024-06-11 如果删除的历史对话,就是当前对话,那就要跳到新开对话页面 + if (chatSession?.uuid == e.uuid) { + setState(() { + chatSession = null; + messages.clear(); + }); + } + } + }); + }, + icon: Icon( + Icons.delete, + size: 16.sp, + color: Theme.of(context).primaryColor, + ), + iconSize: 18.sp, + padding: EdgeInsets.all(0.sp), + ), + ); + } + + _buildUpdateBotton(ChatSession e) { + return SizedBox( + width: 40.sp, + child: IconButton( + onPressed: () { + setState(() { + _selectedTitleController.text = e.title; + }); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text("修改对话记录标题:", style: TextStyle(fontSize: 18.sp)), + content: TextField( + controller: _selectedTitleController, + maxLines: 2, + // autofocus: true, + // onChanged: (v) { + // print("onChange: $v"); + // }, + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: const Text("取消"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: const Text("确定"), + ), + ], + ); + }, + ).then((value) async { + if (value == true) { + var temp = e; + temp.title = _selectedTitleController.text.trim(); + // 修改对话的标题 + _dbHelper.updateChatSession(temp); + + // 修改成功后重新查询更新 + var list = await getHsitoryChats(); + + setState(() { + chatHsitory = list; + }); + } + }); + }, + icon: Icon( + Icons.edit, + size: 16.sp, + color: Theme.of(context).primaryColor, + ), + iconSize: 18.sp, + ), + ); + } + + /// 修改当前正在对话的自动生成对话的标题 + updateChatTile() { + setState(() { + _titleController.text = chatSession!.title; + }); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text("修改对话标题:", style: TextStyle(fontSize: 20.sp)), + content: TextField( + controller: _titleController, + maxLines: 3, + // autofocus: true, + // onChanged: (v) { + // print("onChange: $v"); + // }, + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: const Text("取消"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: const Text("确定"), + ), + ], + ); + }, + ).then((value) async { + if (value == true) { + var temp = chatSession!; + temp.title = _titleController.text.trim(); + // 修改对话的标题 + _dbHelper.updateChatSession(temp); + + // 修改后更新标题 + setState(() { + chatSession = temp; + }); + + // // 修改成功后重新查询更新(理论上不用重新查询应该也没问题) + // var b = await _dbHelper.queryChatList(uuid: chatSession!.uuid); + // setState(() { + // chatSession = b.first; + // }); + } + }); + } + + _buildCusConfigRow(String label, String value) { + return Row( + children: [ + Expanded( + flex: 1, + child: Text(label, style: TextStyle(fontSize: 10.sp)), + ), + Expanded( + flex: 5, + child: Text(value, style: TextStyle(fontSize: 10.sp)), + ), + ], + ); + } + + /// 构建切换平台和模型的行 + buildPlatAndLlmRow() { + List cpWidgetList = [ + const Text("平台:"), + SizedBox(width: 10.sp), + SizedBox( + width: 52.sp, + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey, width: 1.0), + borderRadius: BorderRadius.circular(4), + ), + child: DropdownButton( + value: selectedPlatform, + isDense: true, + alignment: AlignmentDirectional.center, + items: buildCloudPlatforms(), + onChanged: onCloudPlatformChanged, + ), + ), + ), + ]; + + /// 2024-06-20 + /// 如果是用户配置的平台和模型(目前仅支持单个配置)、就只能使用那一个。 + /// 所以没有切换的row,但给用户显示自己配置的平台、模型、和appid及key + if (widget.isUserConfig == true) { + return Row(children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCusConfigRow("平台", selectedPlatform.name), + _buildCusConfigRow("模型", newLLMSpecs[selectedLlm]!.model), + _buildCusConfigRow("AppId", + getIdAndKeyFromPlatform(selectedPlatform)['id'] ?? ""), + _buildCusConfigRow("AppKey", + getIdAndKeyFromPlatform(selectedPlatform)['key'] ?? ""), + ], + ), + ), + TextButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const UserCusModelStepper(), + ), + ); + }, + child: const Text("重新配置"), + ), + ]); + } + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.isLimitedTest != true) ...cpWidgetList, + const Text("模型:"), + SizedBox(width: 10.sp), + Expanded( + // 下拉框有个边框,需要放在容器中 + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey, width: 1.0), + borderRadius: BorderRadius.circular(4), + ), + child: DropdownButton( + value: selectedLlm, + isDense: true, + alignment: AlignmentDirectional.bottomEnd, + menuMaxHeight: 300.sp, + items: buildPlatformLLMs(), + onChanged: (val) { + setState(() { + selectedLlm = val!; + // 2024-06-15 切换模型应该新建对话,因为上下文丢失了。 + // 建立新对话就是把已有的对话清空就好(因为保存什么的在发送消息时就处理了) + chatSession = null; + messages.clear(); + }); + }, + ), + ), + ), + IconButton( + onPressed: () { + commonHintDialog( + context, + "模型说明", + newLLMSpecs[selectedLlm]!.spec ?? "", + ); + }, + icon: Icon(Icons.help, size: 18.sp), + iconSize: 20.sp, + ), + // + ], + ); + } + + /// 直接进入对话页面,展示预设问题的区域 + buildDefaultQuestionArea() { + return [ + Text("你可以试着问我(对话总长度不宜超过${newLLMSpecs[selectedLlm]!.contextLength}字):"), + Expanded( + flex: 2, + child: ListView.builder( + itemCount: defaultQuestions.length, + itemBuilder: (context, index) { + // 构建MessageItem + return InkWell( + onTap: () { + _sendMessage(defaultQuestions[index]); + }, + child: Card( + elevation: 2, + color: Colors.teal[100], + child: Container( + decoration: BoxDecoration( + // 设置圆角半径为10 + borderRadius: BorderRadius.all(Radius.circular(15.sp)), + ), + padding: EdgeInsets.all(8.sp), + child: Text(defaultQuestions[index]), + ), + ), + ); + }, + ), + ), + ]; + } + + /// 对话的标题区域 + buildChatTitleArea() { + // 点击可修改标题 + return Padding( + padding: EdgeInsets.all(5.sp), + child: Row( + children: [ + const Icon(Icons.title), + SizedBox(width: 10.sp), + Expanded( + child: Text( + '${(chatSession != null) ? chatSession?.title : '<暂未建立对话>'}', + maxLines: 2, + softWrap: true, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.bold, + // color: Theme.of(context).primaryColor, + ), + // textAlign: TextAlign.center, + ), + ), + SizedBox( + width: 56.sp, + child: IconButton( + onPressed: () { + if (chatSession != null) { + updateChatTile(); + } + }, + icon: Icon( + Icons.edit, + size: 18.sp, + color: Theme.of(context).primaryColor, + ), + ), + ), + ], + ), + ); + } + + /// 构建对话列表主体 + buildChatListArea() { + return Expanded( + child: ListView.builder( + controller: _scrollController, // 设置ScrollController + // reverse: true, // 反转列表,使新消息出现在底部 + itemCount: messages.length, + itemBuilder: (context, index) { + // 构建MessageItem + return Padding( + padding: EdgeInsets.all(5.sp), + child: Column( + children: [ + // 如果是最后一个回复的文本,使用打字机特效 + // if (index == messages.length - 1) + // TypewriterText(text: messages[index].text), + MessageItem(message: messages[index]), + // 如果是大模型回复,可以有一些功能按钮 + if (!messages[index].isFromUser) + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // 其中,是大模型最后一条回复,则可以重新生成 + // 注意,还要排除占位消息 + // 限量的没有重新生成,因为不好计算tokens总数 + if ((index == messages.length - 1) && + messages[index].isPlaceholder != true && + selectedPlatform != CloudPlatform.limited) + TextButton( + onPressed: () { + regenerateLatestQuestion(); + }, + child: const Text("重新生成"), + ), + // + // 如果不是等待响应才可以点击复制该条回复 + if (messages[index].isPlaceholder != true) + IconButton( + onPressed: () { + Clipboard.setData( + ClipboardData(text: messages[index].text), + ); + + EasyLoading.showToast( + "已复制到剪贴板", + duration: const Duration(seconds: 3), + toastPosition: EasyLoadingToastPosition.center, + ); + }, + icon: Icon(Icons.copy, size: 20.sp), + ), + // 如果不是等待响应才显示token数量 + if (messages[index].isPlaceholder != true) + Text( + "tokens 输入:${messages[index].inputTokens} 输出:${messages[index].outputTokens} 总计:${messages[index].totalTokens}", + style: TextStyle(fontSize: 10.sp), + ), + SizedBox(width: 10.sp), + ], + ) + ], + ), + ); + }, + ), + ); + } + + /// 用户发送消息的区域 + buildUserSendArea() { + return Padding( + padding: EdgeInsets.all(5.sp), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _userInputController, + decoration: const InputDecoration( + hintText: '可以向我提任何问题哦 ٩(๑❛ᴗ❛๑)۶', + border: OutlineInputBorder(), // 添加边框 + ), + maxLines: 5, + minLines: 1, + onChanged: (String? text) { + if (text != null) { + setState(() { + userInput = text.trim(); + }); + } + }, + ), + ), + IconButton( + // 如果AI正在响应,或者输入框没有任何文字,不让点击发送 + onPressed: isBotThinking || userInput.isEmpty + ? null + : () { + if (!isMessageTooLong()) { + // 在当前上下文中查找最近的 FocusScope 并使其失去焦点,从而收起键盘。 + FocusScope.of(context).unfocus(); + + // 用户发送消息 + _sendMessage(userInput); + + // 发送完要清空记录用户输的入变量 + setState(() { + userInput = ""; + }); + } else { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('对话过长'), + content: const Text( + '注意,由于免费API的使用压力,单个聊天对话的总长度不能超过8000字,请新开对话,谢谢。', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('确定'), + ), + ], + ); + }, + ); + } + }, + icon: const Icon(Icons.send), + ), + ], + ), + ); + } +} diff --git a/lib/views/agi_llm_sample/widgets/message_item.dart b/lib/views/agi_llm_sample/widgets/message_item.dart new file mode 100644 index 0000000..dcbb9d0 --- /dev/null +++ b/lib/views/agi_llm_sample/widgets/message_item.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:intl/intl.dart'; + +import '../../../common/constants.dart'; +import '../../../models/llm_chat_state.dart'; + +class MessageItem extends StatelessWidget { + final ChatMessage message; + + const MessageItem({super.key, required this.message}); + + @override + Widget build(BuildContext context) { + // 根据是否是用户输入跳转文本内容布局 + bool isFromUser = message.isFromUser; + + // 如果是用户输入,头像显示在右边 + CrossAxisAlignment crossAlignment = + isFromUser ? CrossAxisAlignment.end : CrossAxisAlignment.start; + + // 所有的文字颜色,暂定用户蓝色AI黑色 + Color textColor = isFromUser ? Colors.blue : Colors.black; + + /// 这里暂时不考虑外边框的距离,使用时在外面加padding之类的 + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 头像,展示机器和用户用于区分即可 + // 如果是AI回复的,头像在前面;用户发的,头像在Row最后面 + if (!isFromUser) + CircleAvatar( + radius: 18.sp, + backgroundColor: Colors.grey, + child: const Icon(Icons.code), // Icons.bolt/lightbulb + ), + SizedBox(width: 3.sp), // 头像和文本之间的间距 + // 消息内容 + Expanded( + child: Column( + crossAxisAlignment: crossAlignment, + children: [ + // 这里可以根据需要添加时间戳等 + Padding( + padding: EdgeInsets.symmetric(horizontal: 3.sp), + child: Text( + DateFormat(constDatetimeFormat).format(message.dateTime), + // 根据来源设置不同颜色 + style: TextStyle(fontSize: 12.sp, color: textColor), + ), + ), + // 如果是占位的消息,则显示装圈圈 + if (message.isPlaceholder == true) + Card( + elevation: 3, + child: Padding( + padding: EdgeInsets.all(5.sp), + child: Row( + crossAxisAlignment: crossAlignment, + children: [ + Text( + message.text, + style: const TextStyle(color: Colors.black), + ), + SizedBox( + height: 20.sp, + width: 20.sp, + child: const CircularProgressIndicator(), + ), + ], + ), + ), + ), + + // 如果不是占位的消息,则正常显示 + if (message.isPlaceholder != true) + Card( + elevation: 3, + child: Padding( + padding: EdgeInsets.all(5.sp), + + /// 这里考虑根据 格式等格式化显示内容 + child: SingleChildScrollView( + // 2024-05-31 现在版本长按选中会报错,信息类似如下: + // https://github.com/flutter/flutter/issues/148792 + // 所以暂时不让选择 + child: MarkdownBody( + data: message.text, + selectable: true, + // 设置Markdown文本全局样式 + styleSheet: MarkdownStyleSheet( + // 普通段落文本颜色(假定用户输入就是普通段落文本) + p: TextStyle(color: textColor), + // ... 其他级别的标题样式 + // 可以继续添加更多Markdown元素的样式 + ), + ), + // Text( + // message.text, + // // 根据来源设置不同颜色 + // style: TextStyle(color: textColor), + // ), + ), + ), + ), + ], + ), + ), + // 如果是用户发的,头像在Row最后面 + if (isFromUser) + CircleAvatar( + radius: 18.sp, + backgroundColor: Colors.lightBlue, + child: const Icon(Icons.person), + ), + ], + ); + } +} diff --git a/lib/views/home_page.dart b/lib/views/home_page.dart new file mode 100644 index 0000000..65901fe --- /dev/null +++ b/lib/views/home_page.dart @@ -0,0 +1,176 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'accounting/index.dart'; +import 'agi_llm_sample/index.dart'; +import 'user_and_settings/backup_and_restore/index.dart'; +import 'random_dish/dish_wheel_index.dart'; +import 'user_and_settings/index.dart'; + +/// 主页面 + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + int _selectedIndex = 0; + + static const List _widgetOptions = [ + AgiLlmSample(), + BillItemIndex(), + DishWheelIndex(), + UserAndSettings(), + ]; + + void _onItemTapped(int index) { + setState(() { + _selectedIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + return PopScope( + // 点击返回键时暂停返回 + canPop: false, + onPopInvoked: (didPop) async { + print("didPop-----------$didPop"); + if (didPop) { + return; + } + // final NavigatorState navigator = Navigator.of(context); + // 如果确认弹窗点击确认返回true,否则返回false + final bool? shouldPop = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("退出确认"), + content: const Text("确认退出 AI Light Life 吗?"), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, false); + }, + child: const Text("取消"), + ), + TextButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text("确认"), + ), + ], + ); + }, + ); // 只有当对话框返回true 才 pop(返回上一层) + if (shouldPop ?? false) { + // 如果还有可以关闭的导航,则继续pop + // if (navigator.canPop()) { + // navigator.pop(); + // } else { + // // 如果已经到头来,则关闭应用程序 + // SystemNavigator.pop(); + // } + + // 2024-05-29 已经到首页了,直接退出 + SystemNavigator.pop(); + } + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + // 这外面有appbar,其实内部页面不应该是Scaffold??? + // appBar: AppBar( + // title: const Text("这里外面有appbar"), + // ), + // home页的背景色(如果下层还有设定其他主题颜色,会被覆盖) + // backgroundColor: Colors.red, + body: Center(child: _widgetOptions.elementAt(_selectedIndex)), + + // 两种底部导航条 + bottomNavigationBar: BottomNavigationBar( + // 当item数量小于等于3时会默认fixed模式下使用主题色,大于3时则会默认shifting模式下使用白色。 + // 为了使用主题色,这里手动设置为fixed + type: BottomNavigationBarType.fixed, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.bolt), + label: "智能助手", + ), + BottomNavigationBarItem( + icon: Icon(Icons.receipt), + label: "极简记账", + ), + BottomNavigationBarItem( + icon: Icon(Icons.restaurant_menu), + label: "随机菜品", + ), + BottomNavigationBarItem( + icon: Icon(Icons.person), + label: "用户设置", + ), + ], + currentIndex: _selectedIndex, + onTap: _onItemTapped, + ), + drawer: Drawer( + // 将ListView添加到抽屉中。这确保了如果没有足够的垂直空间容纳所有东西,用户可以滚动抽屉中的选项。 + child: ListView( + // 从ListView中删除任何内边距填充。 + padding: EdgeInsets.zero, + children: [ + const DrawerHeader( + decoration: BoxDecoration(color: Colors.blue), + child: Text('个人中心(占位)'), + ), + ListTile( + title: const Text('智能对话'), + selected: _selectedIndex == 0, + onTap: () { + // 更新选中页面 + _onItemTapped(0); + // 关闭抽屉 + Navigator.pop(context); + }, + ), + ListTile( + title: const Text('极简记账'), + selected: _selectedIndex == 1, + onTap: () { + _onItemTapped(1); + Navigator.pop(context); + }, + ), + ListTile( + title: const Text('随机菜品'), + selected: _selectedIndex == 2, + onTap: () { + _onItemTapped(2); + Navigator.pop(context); + }, + ), + ListTile( + title: const Text('备份恢复'), + onTap: () { + Navigator.pop(context); + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const BackupAndRestore(), + ), + ); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/random_dish/demo_apis.dart b/lib/views/random_dish/demo_apis.dart new file mode 100644 index 0000000..d6806d5 --- /dev/null +++ b/lib/views/random_dish/demo_apis.dart @@ -0,0 +1,48 @@ +// ignore_for_file: avoid_print + +import 'dart:math'; + +import 'package:uuid/uuid.dart'; + +import '../../common/components/tool_widget.dart'; +import '../../common/constants.dart'; +import '../../common/db_tools/db_helper.dart'; +import '../../models/dish.dart'; + +final DBHelper _dbHelper = DBHelper(); + +Future> insertDemoDish({int? size = 10}) async { + print("【【【插入测试数据 start-->:insertDemoFood "); + + var foods = List.generate( + size ?? 10, + (index) => Dish( + dishId: const Uuid().v1(), + dishName: generateRandomString(5, 20), + description: generateRandomString(50, 100), + photos: [ + generateRandomString(10, 50), + generateRandomString(10, 50), + generateRandomString(10, 50), + ].join(","), + // 随机获取几个标签和分类,拼到一起 + tags: List.generate( + Random().nextInt(5) + 1, + (index) => + dishTagOptions[Random().nextInt(dishTagOptions.length)].value, + ).join(","), + mealCategories: List.generate( + Random().nextInt(5) + 1, + (index) => + dishCateOptions[Random().nextInt(dishCateOptions.length)].value, + ).join(","), + recipe: generateRandomString(50, 100), + ), + ); + + var rst = await _dbHelper.insertDishList(foods); + + print("【【【插入测试数据 end-->:insertDemoFood "); + + return rst; +} diff --git a/lib/views/random_dish/dish_detail.dart b/lib/views/random_dish/dish_detail.dart new file mode 100644 index 0000000..c1c9877 --- /dev/null +++ b/lib/views/random_dish/dish_detail.dart @@ -0,0 +1,272 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../common/components/tool_widget.dart'; +import '../../common/db_tools/db_helper.dart'; +import '../../models/dish.dart'; + +import 'dish_modify.dart'; + +class DishDetail extends StatefulWidget { + // 这个是食物搜索页面点击食物进来详情页时传入的数据 + final Dish dishItem; + + const DishDetail({super.key, required this.dishItem}); + + @override + State createState() => _DishDetailState(); +} + +class _DishDetailState extends State { + final DBHelper _dbHelper = DBHelper(); + + // 构建食物的单份营养素列表,可以多选,然后进行相关操作 + // 待上传的动作数量已经每个动作的选中状态 + int servingItemsNum = 0; + List servingSelectedList = [false]; + + // 传入的食物详细数据 + late Dish dishInfo; + + // 数据是否被修改 + // (这个标志要返回,如果有被修改,返回上一页列表时要重新查询;没有被修改则不用重新查询) + bool isModified = false; + + @override + void initState() { + super.initState(); + setState(() { + dishInfo = widget.dishItem; + }); + } + + // 在修改了菜品基本信息后,重新查询该菜品 + refreshDishInfo() async { + var newItem = await _dbHelper.queryDishList( + dishId: widget.dishItem.dishId, + ); + + if (newItem.data.isNotEmpty) { + setState(() { + dishInfo = (newItem.data as List)[0]; + }); + } + } + + /// 新结构,上面是食物基本信息,下面是单份营养素详情表格; + /// 右上角修改按钮,修改基本信息; + /// 表格的单份营养素可选中索引进行删除,可新增; + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvoked: (didPop) async { + if (didPop) return; + + // 返回上一页时,返回是否被修改标识,用于父组件判断是否需要重新查询 + Navigator.pop(context, isModified); + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + title: const Text("菜品详情"), + actions: [ + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DishModify(dish: dishInfo), + ), + ).then((value) { + // 不管是否修改成功,这里都重新加载 + // 还是稍微判断一下吧 + if (value != null && value == true) { + refreshDishInfo(); + // 被修改的标志也改一下,传给上一层进行刷新 + isModified = true; + } + }); + }, + icon: const Icon(Icons.edit), + ), + ], + ), + body: ListView( + children: [ + /// 展示食物基本信息表格 + ...buildDishTable(dishInfo), + ], + ), + ), + ); + } + + /// 表格显示食物基本信息 + buildDishTable(Dish dish) { + List imageList = []; + // 先要排除image是个空字符串在分割 + if (dish.photos != null && dish.photos!.trim().isNotEmpty) { + imageList = dish.photos!.split(","); + } + + return [ + Text( + dish.dishName, + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.bold, + color: Colors.green, + ), + textAlign: TextAlign.center, + ), + + _buildTitleText("菜品参考图片"), + buildImageCarouselSlider(imageList), + _buildTitleText("菜品基本信息"), + Padding( + padding: EdgeInsets.all(10.sp), + child: Table( + // 设置表格边框 + border: TableBorder.all(color: Theme.of(context).disabledColor), + // 设置每列的宽度占比 + columnWidths: const { + 0: FlexColumnWidth(5), + 1: FlexColumnWidth(17), + }, + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + _buildTableRow("菜品名称", dish.dishName), + _buildTableRow("菜品介绍", dish.description ?? ""), + _buildTableRow("菜品分类", dish.tags ?? ""), + _buildTableRow("菜品餐次", dish.mealCategories ?? ""), + // _buildTableRow("菜谱", dish.recipe ?? ""), + // _buildTableRow("图片地址", dish.photos ?? ""), + _buildTableRow("视频地址", dish.videos ?? ""), + if (dish.videos != null && dish.videos != "") + TableRow( + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 10.sp), + child: Text( + "视频教程", + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 10.sp), + child: TextButton( + onPressed: () async { + if (!await launchUrl( + Uri.parse(dish.videos!.split(",")[0]))) { + throw Exception('视频链接无法跳转'); + } + }, + child: const Text('点击跳转观看'), + ), + ), + ], + ), + ], + ), + ), + + /// 菜谱单独列出来 + Padding( + padding: EdgeInsets.all(10.sp), + child: Table( + // 设置表格边框 + border: TableBorder.all(color: Theme.of(context).disabledColor), + // 设置每列的宽度占比 + columnWidths: const { + 0: FlexColumnWidth(10), + }, + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + TableRow( + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 10.sp), + child: Text( + "菜谱及图片", + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + _buildTableRow(null, dish.recipe ?? "", valueFontSize: 16.sp), + ], + ), + ), + + if (dish.recipePicture != null && dish.recipePicture != "") + buildClickImageDialog(context, dish.recipePicture!) + ]; + } + + // 标题文字 + _buildTitleText( + String title, { + double? fontSize = 16, + Color? color = Colors.black54, + TextAlign? textAlign = TextAlign.start, + }) { + return Padding( + padding: EdgeInsets.all(10.sp), + child: Text( + title, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: color, + ), + textAlign: textAlign, + ), + ); + } + + // 构建食物基本信息表格的行数据 + _buildTableRow( + String? label, + String value, { + double? labelFontSize, + double? valueFontSize, + }) { + return TableRow( + children: [ + if (label != null) + Padding( + padding: EdgeInsets.symmetric(horizontal: 10.sp), + child: Text( + label, + style: TextStyle( + fontSize: labelFontSize ?? 14.sp, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 10.sp), + child: Text( + value, + style: TextStyle( + fontSize: valueFontSize ?? 14.sp, + color: Colors.black87, + ), + textAlign: TextAlign.left, + ), + ), + ], + ); + } +} diff --git a/lib/views/random_dish/dish_json_import.dart b/lib/views/random_dish/dish_json_import.dart new file mode 100644 index 0000000..2a5479f --- /dev/null +++ b/lib/views/random_dish/dish_json_import.dart @@ -0,0 +1,454 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:uuid/uuid.dart'; + +import '../../common/components/tool_widget.dart'; +import '../../common/db_tools/db_helper.dart'; +import '../../models/dish.dart'; + +/// 只支持导入json文件,不再支持文件夹 +class DishJsonImport extends StatefulWidget { + const DishJsonImport({super.key}); + + @override + State createState() => _DishJsonImportState(); +} + +class _DishJsonImportState extends State { + final DBHelper _dbHelper = DBHelper(); + + // 是否在解析json中或导入数据库中 + bool isLoading = false; + // 文件解析后的菜品信息 + List dishes = []; + + // 构建json文件加载成功后的锻炼数据表格要用到 + // 待上传的动作数量已经每个动作的选中状态 + int dishItemsNum = 0; + List dishSelectedList = [false]; + + // 用户可以选择多个json文件 + Future _openJsonFiles() async { + FilePickerResult? result = + await FilePicker.platform.pickFiles(allowMultiple: true); + if (result != null) { + setState(() { + isLoading = true; + }); + + for (File file in result.files.map((file) => File(file.path!))) { + if (file.path.toLowerCase().endsWith('.json')) { + try { + String jsonData = await file.readAsString(); + + // 如果一个json文件只是一个动作,那就加上中括号;如果本身就是带了中括号的多个,就不再加 + List dishMapList = + jsonData.trim().startsWith("[") && jsonData.trim().endsWith("]") + ? json.decode(jsonData) + : json.decode("[$jsonData]"); + + var temp = + dishMapList.map((e) => JsonFileDish.fromJson(e)).toList(); + + setState(() { + dishes.addAll(temp); + // 更新需要构建的表格的长度和每条数据的可选中状态 + dishItemsNum = dishes.length; + dishSelectedList = + List.generate(dishItemsNum, (int index) => false); + }); + } catch (e) { + // 弹出报错提示框 + if (!mounted) return; + + commonExceptionDialog( + context, + "导入json文件错误", + "错误文件${file.path},\n 错误信息${e.toString}", + ); + + setState(() { + isLoading = false; + }); + // 中止操作 + return; + } + } + } + setState(() { + isLoading = false; + }); + } else { + // User canceled the picker + return; + } + } + + // 讲json数据保存到数据库中 + _saveToDb() async { + if (isLoading) return; + + setState(() { + isLoading = true; + }); + // 这里导入去重的工作要放在上面解析文件时,这里就全部保存了。 + // 而且id自增,食物或者编号和数据库重复,这里插入数据库中也不会报错。 + for (var e in dishes) { + var tempDish = Dish( + // 转型会把前面的0去掉(让id自增,否则下面serving的id也要指定) + dishId: const Uuid().v1(), + dishName: e.dishName ?? "", + description: e.description ?? "", + tags: e.tags ?? "", + mealCategories: e.mealCategories ?? "", + // ???这里假设传入的图片是完整的 + photos: e.images?.join(","), + videos: e.videos?.join(","), + // json描述是字符串数组,直接用换行符拼接 + recipe: e.recipe?.join("\n\n"), + recipePicture: e.recipePicture, + ); + + try { + await _dbHelper.insertDishList([tempDish]); + } on Exception catch (e) { + // 将错误信息展示给用户 + if (!mounted) return; + commonExceptionDialog(context, "异常提醒", e.toString()); + + setState(() { + isLoading = false; + }); + return; + } + } + // 保存完了,情况数据,并弹窗提示。 + setState(() { + setState(() { + dishes = []; + // 更新需要构建的表格的长度和每条数据的可选中状态 + dishItemsNum = 0; + dishSelectedList = [false]; + + isLoading = false; + }); + }); + + if (!mounted) return; + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("菜品导入"), + content: const Text("菜品已成功导入!"), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text("确定"), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + "导入菜品", + style: TextStyle(fontSize: 20.sp), + ), + actions: [ + IconButton( + onPressed: dishes.isNotEmpty ? _saveToDb : null, + icon: Icon( + Icons.save, + color: dishes.isNotEmpty ? null : Theme.of(context).disabledColor, + ), + ), + ], + ), + body: isLoading + ? buildLoader(isLoading) + : Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + /// 最上方的功能按钮区域 + buildButtonsArea(), + + /// 食物组成列表不为空且大于50条,简单的列表展示 + if (dishes.isNotEmpty && dishes.length > 50) + ...buildDishListArea(), + + /// 食物组成列表不为空且不大于50条,简单的表格展示 + if (dishes.isNotEmpty && dishes.length <= 50) + ...buildDishDataTable(), + ], + ), + ); + } + + /// 构建功能按钮区 + buildButtonsArea() { + return Card( + elevation: 5, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Expanded( + child: TextButton.icon( + onPressed: _openJsonFiles, + icon: const Icon(Icons.file_upload), + label: const Text("选择文件"), + ), + ), + Expanded( + child: TextButton.icon( + onPressed: () { + setState(() { + dishes = []; + // 更新需要构建的表格的长度和每条数据的可选中状态 + dishItemsNum = 0; + dishSelectedList = [false]; + }); + }, + icon: Icon( + Icons.clear, + color: dishes.isNotEmpty + ? Theme.of(context).primaryColor + : Theme.of(context).disabledColor, + ), + label: Text( + "清空所有", + style: TextStyle( + color: dishes.isNotEmpty + ? Theme.of(context).primaryColor + : Theme.of(context).disabledColor, + ), + ), + ), + ), + ], + ), + ); + } + + /// 当上传的食物营养素信息超过50条,就单纯的列表展示 + buildDishListArea() { + return [ + RichText( + textAlign: TextAlign.left, + text: TextSpan( + children: [ + TextSpan( + text: "共${dishes.length}条", + style: TextStyle(fontSize: 14.sp, color: Colors.blue), + ), + TextSpan( + text: "从左往右为:索引-菜品名称-标签", + style: TextStyle(fontSize: 14.sp, color: Colors.green), + ), + ], + ), + ), + SizedBox(height: 10.sp), + Expanded( + child: ListView.builder( + itemCount: dishes.length, + itemBuilder: (context, index) { + return Row( + verticalDirection: VerticalDirection.up, + children: [ + Expanded( + child: RichText( + textAlign: TextAlign.start, + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan( + children: [ + // 简单固定下宽度 + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: 60.sp), + child: Padding( + padding: EdgeInsets.only(left: 10.sp), + child: Text( + '${index + 1}', + style: TextStyle( + fontSize: 12.sp, + color: Colors.green, + ), + ), + ), + ), + ), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: 160.sp), + child: Text( + "${dishes[index].dishName}", + style: TextStyle( + fontSize: 12.sp, + color: Colors.grey, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + // TextSpan( + // text: '${index + 1} - ', + // style: TextStyle( + // fontSize: 12.sp, + // color: Colors.green, + // ), + // ), + // TextSpan( + // text: "${dishes[index].dishName} - ", + // style: TextStyle( + // fontSize: 12.sp, + // color: Colors.grey, + // ), + // ), + TextSpan( + text: "${dishes[index].tags}", + style: TextStyle( + fontSize: 12.sp, + color: Colors.red, + ), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ]; + } + + /// 当上传的食物营养素信息不超过50条,可以表格管理 + buildDishDataTable() { + return [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 10.sp), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "上传条目", + style: TextStyle(fontSize: 16.sp), + textAlign: TextAlign.start, + ), + TextButton( + onPressed: () { + setState(() { + // 先找到被选中的索引 + List trueIndices = + List.generate(dishSelectedList.length, (index) => index) + .where((i) => dishSelectedList[i]) + .toList(); + + // 从列表中移除 + // 倒序遍历需要移除的索引列表,以避免索引变化导致的问题 + for (int i = trueIndices.length - 1; i >= 0; i--) { + dishes.removeAt(trueIndices[i]); + } + // 更新需要构建的表格的长度和每条数据的可选中状态 + dishItemsNum = dishes.length; + dishSelectedList = List.generate( + dishItemsNum, + (int index) => false, + ); + }); + }, + child: Text( + "移除选中", + style: TextStyle(fontSize: 16.sp), + ), + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + child: DataTable( + dataRowMinHeight: 20.sp, // 设置行高范围 + // dataRowMaxHeight: 80.sp, + headingRowHeight: 25, // 设置表头行高 + horizontalMargin: 10, // 设置水平边距 + columnSpacing: 15.sp, // 设置列间距 + columns: const [ + DataColumn(label: Text("菜品名称")), + DataColumn(label: Text("菜品分类")), + ], + rows: List.generate( + dishItemsNum, + (int index) => DataRow( + color: WidgetStateProperty.resolveWith( + (Set states) { + // All rows will have the same selected color. + if (states.contains(WidgetState.selected)) { + return Theme.of(context) + .colorScheme + .primary + .withOpacity(0.08); + } + // Even rows will have a grey color. + if (index.isEven) { + return Colors.grey.withOpacity(0.3); + } + return null; // Use default value for other states and odd rows. + }), + cells: [ + DataCell( + SizedBox( + width: 0.35.sw, + child: Wrap( + children: [ + Text( + '${dishes[index].dishName}', + style: TextStyle(fontSize: 12.sp), + ), + ], + ), + ), + ), + DataCell( + SizedBox( + width: 0.55.sw, + child: Text( + '${dishes[index].tags}', + style: TextStyle(fontSize: 12.sp), + textAlign: TextAlign.left, + ), + ), + ), + ], + selected: dishSelectedList[index], + onSelectChanged: (bool? value) { + setState(() { + dishSelectedList[index] = value!; + }); + }, + ), + ), + ), + ), + ) + ]; + } +} diff --git a/lib/views/random_dish/dish_list.dart b/lib/views/random_dish/dish_list.dart new file mode 100644 index 0000000..9d9f4e9 --- /dev/null +++ b/lib/views/random_dish/dish_list.dart @@ -0,0 +1,651 @@ +import 'dart:async'; +import 'dart:developer' as developer; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../common/components/tool_widget.dart'; +import '../../common/constants.dart'; +import '../../common/db_tools/db_helper.dart'; +import '../../common/utils/tools.dart'; +import '../../models/dish.dart'; + +import 'dish_detail.dart'; +import 'dish_json_import.dart'; +import 'dish_modify.dart'; + +/// 菜品列表,点击进入详情;长按删除弹窗。 +/// 顶部按钮:切换列表展示样式(图片/文字)、导入json文件、新增菜品 +class DishList extends StatefulWidget { + const DishList({Key? key}) : super(key: key); + + @override + State createState() => _DishListState(); +} + +class _DishListState extends State { + final DBHelper _dbHelper = DBHelper(); + + List dishItems = []; + // 食物的总数(查询时则为符合条件的总数,默认一页只有10条,看不到总数量) + int itemsCount = 0; + int currentPage = 1; // 数据库查询的时候会从0开始offset + int pageSize = 10; + bool isLoading = false; + ScrollController scrollController = ScrollController(); + TextEditingController searchController = TextEditingController(); + String query = ''; + + // 当前网络状态相关栏位 + List _connectionStatus = [ConnectivityResult.none]; + final Connectivity _connectivity = Connectivity(); + late StreamSubscription> _connectivitySubscription; + + // 默认不使用卡片列表,节约流量 + bool isDishCardList = false; + + // 是否授权访问存储 + bool isPermissionGranted = false; + + @override + void initState() { + super.initState(); + + _getPermission(); + + _loadDishData(); + + scrollController.addListener(_scrollListener); + + initConnectivity(); + + _connectivitySubscription = + _connectivity.onConnectivityChanged.listen(_updateConnectionStatus); + } + + @override + void dispose() { + scrollController.removeListener(_scrollListener); + scrollController.dispose(); + searchController.dispose(); + + _connectivitySubscription.cancel(); + super.dispose(); + } + + _getPermission() async { + bool flag = await requestPermission(); + setState(() { + isPermissionGranted = flag; + }); + } + + // 平台消息是异步的,因此我们使用异步方法进行初始化。 + Future initConnectivity() async { + late List result; + // 平台消息可能会失败,因此我们使用try/catch PlatformException。 + try { + result = await _connectivity.checkConnectivity(); + } on PlatformException catch (e) { + developer.log('Couldn\'t check connectivity status', error: e); + return; + } + + // 如果在异步平台消息运行时从树中删除了小部件,希望是丢弃回复,而不是调用setState来更新我们不存在的外观。 + if (!mounted) { + return Future.value(null); + } + + return _updateConnectionStatus(result); + } + + Future _updateConnectionStatus(List result) async { + setState(() { + _connectionStatus = result; + }); + } + + Future _loadDishData() async { + if (isLoading) return; + + setState(() { + isLoading = true; + }); + + CusDataResult temp = await _dbHelper.queryDishList( + dishName: query, + page: currentPage, + pageSize: pageSize, + ); + + var newData = temp.data as List; + + setState(() { + dishItems.addAll(newData); + itemsCount = temp.total; + + currentPage++; + isLoading = false; + }); + } + + void _scrollListener() { + if (isLoading) return; + + final maxScrollExtent = scrollController.position.maxScrollExtent; + final currentPosition = scrollController.position.pixels; + final delta = 50.0.sp; + + if (maxScrollExtent - currentPosition <= delta) { + // 2024-03-22 没有达到最大值时滚动才加载更多 + if (itemsCount > dishItems.length) { + _loadDishData(); + } + } + } + + void _handleSearch() { + setState(() { + dishItems.clear(); + currentPage = 1; + query = searchController.text; + }); + // 在当前上下文中查找最近的 FocusScope 并使其失去焦点,从而收起键盘。 + FocusScope.of(context).unfocus(); + + _loadDishData(); + } + + // 进入json文件导入前,先获取权限 + clickExerciseImport() { + // 用户授权了访问内部存储权限,可以跳转到导入 + if (isPermissionGranted) { + if (!mounted) return; + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const DishJsonImport(), + ), + ).then((value) { + setState(() { + dishItems.clear(); + currentPage = 1; + }); + _loadDishData(); + }); + } else { + showSnackMessage(context, "无权访问内部存储!"); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: RichText( + text: TextSpan( + children: [ + TextSpan( + text: "菜品", + style: TextStyle( + fontSize: 20.sp, + color: Theme.of(context).primaryColor, + ), + ), + TextSpan( + text: "\n数量 $itemsCount 已加载 ${dishItems.length}", + style: TextStyle( + fontSize: 12.sp, + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + ), + actions: [ + // IconButton( + // icon: const Icon(Icons.bug_report), + // onPressed: () { + // insertDemoDish(size: 5); + + // setState(() { + // dishItems.clear(); + // currentPage = 1; + // }); + // _loadDishData(); + // }, + // ), + IconButton( + icon: Icon(isDishCardList ? Icons.list : Icons.grid_3x3), + onPressed: () { + // 如果不是卡片列表,不是wifi状态 但要切到卡片列表 + if (!isDishCardList && + !(_connectionStatus.contains(ConnectivityResult.wifi))) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text("流量预警"), + content: const Text( + "当前非wifi环境,切换卡片列表会加载图片,注意流量消耗!\n网络图片会优先使用本地缓存。", + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: const Text("取消"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: const Text("确认"), + ), + ], + ); + }, + ).then((value) { + if (value == true) { + setState(() { + isDishCardList = !isDishCardList; + }); + } + }); + } else { + // 是wifi的话切来切去无所谓的 + setState(() { + isDishCardList = !isDishCardList; + }); + } + }, + ), + // 导入 + /// 导入json文件 + IconButton( + icon: const Icon(Icons.file_upload), + onPressed: clickExerciseImport, + ), + // 新增 + IconButton( + icon: const Icon(Icons.add), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const DishModify(), + ), + ).then((value) { + if (value != null && value == true) { + setState(() { + dishItems.clear(); + currentPage = 1; + }); + _loadDishData(); + } + }); + }, + ), + ], + ), + body: Column( + children: [ + Padding( + padding: EdgeInsets.all(8.sp), + child: Row( + children: [ + Expanded( + child: TextField( + controller: searchController, + decoration: const InputDecoration( + hintText: "菜品名称关键字", + // 设置透明底色 + filled: true, + fillColor: Colors.transparent, + ), + ), + ), + ElevatedButton( + onPressed: _handleSearch, + child: const Text("搜索"), + ), + ], + ), + ), + Expanded( + child: isDishCardList + ? buildDishCardList(dishItems) + : builDishTileList(dishItems), + ), + ], + ), + ); + } + + /// 卡片列表形式的菜品信息 + buildDishCardList(List dishes) { + return GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 3 / 4, // 初始卡片宽高比例 + ), + itemCount: dishes.length, // 假设有10个菜谱 + itemBuilder: (context, index) { + if (index == dishItems.length) { + return buildLoader(isLoading); + } + + Dish dish = dishes[index]; + + List imageList = []; + // 先要排除image是个空字符串在分割 + if (dish.photos != null && dish.photos!.trim().isNotEmpty) { + imageList = dish.photos!.split(","); + } + + // 获取菜品图片 + var imageUrl = imageList.isNotEmpty ? imageList[0] : null; + + return InkWell( + // 点击跳转菜品详情 + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DishDetail(dishItem: dish), + ), + ).then((value) { + // 从详情页返回后需要重新查询,因为不知道在内部是不是有变动单份营养素。 + // 有变动,退出不刷新,再次进入还是能看到旧的;但是返回就刷新对于只是浏览数据不友好。 + // 因此,详情页会有一个是否被异动的标志,返回true则重新查询;否则就不更新 + if (value != null && value == true) { + setState(() { + dishItems.clear(); + currentPage = 1; + }); + _loadDishData(); + } + }); + }, + // 长按点击弹窗提示是否删除 + onLongPress: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("是否删除该菜品?"), + content: Text("菜品名称:${dish.dishName}"), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, false); + }, + child: const Text("取消"), + ), + TextButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text("确认"), + ), + ], + ); + }, + ).then((value) async { + if (value != null && value) { + try { + await _dbHelper.deleteDishById(dish.dishId); + + // 删除后重新查询 + setState(() { + dishItems.clear(); + currentPage = 1; + }); + _loadDishData(); + } catch (e) { + if (!mounted) return; + // ignore: use_build_context_synchronously + commonExceptionDialog(context, "异常提醒", e.toString()); + } + } + }); + }, + child: Card( + elevation: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 2024-04-09 使用expanded,会使得描述不一样行数时,拉高图片,减少空白; + // Expanded( + // child: AspectRatio( + // aspectRatio: 4 / 3, // 设置图片宽高比例 + // child: imageUrl == null + // ? Image.asset(placeholderImageUrl, + // fit: BoxFit.scaleDown) + // : buildNetworkOrFileImage(imageUrl, fit: BoxFit.cover), + // ), + // ), + // 如果没有,则图片都一样大,可能有不少空白 + AspectRatio( + aspectRatio: 4 / 3, // 设置图片宽高比例 + child: imageUrl == null + ? Image.asset(placeholderImageUrl, fit: BoxFit.scaleDown) + : buildNetworkOrFileImage(imageUrl, fit: BoxFit.cover), + ), + Padding( + padding: EdgeInsets.all(5.0.sp), + child: Text( + dish.dishName, + style: TextStyle( + fontSize: 15.0.sp, + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + Padding( + padding: EdgeInsets.fromLTRB(5.sp, 0, 5.0.sp, 5.0.sp), + child: Text( + "${dish.description}", + style: TextStyle(fontSize: 12.sp, color: Colors.black87), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + }, + controller: scrollController, + ); + } + + /// 列表形式的菜品信息(列表没有预览图,更省流量) + builDishTileList(List dishes) { + return ListView.builder( + itemCount: dishes.length + 1, + itemBuilder: (context, index) { + if (index == dishes.length) { + return buildLoader(isLoading); + } else { + Dish dish = dishes[index]; + + // 先排除原本就是空字符串 + var initTags = (dish.tags != null && dish.tags!.trim().isNotEmpty) + ? dish.tags!.trim().split(",") + : []; + + var initCates = (dish.mealCategories != null && + dish.mealCategories!.trim().isNotEmpty) + ? dish.mealCategories!.trim().split(",") + : []; + + return Card( + elevation: 5, + child: Column( + children: [ + ListTile( + // 菜品名称 + title: Text( + "${index + 1} - ${dish.dishName}", + maxLines: 2, + softWrap: true, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18, + color: Theme.of(context).primaryColor, + ), + ), + // 菜品简介 + subtitle: Text( + "${dish.description}", + style: TextStyle(fontSize: 12.sp), + maxLines: 3, + softWrap: true, + overflow: TextOverflow.ellipsis, + ), + // 点击跳转菜品详情 + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DishDetail(dishItem: dish), + ), + ).then((value) { + // 从详情页返回后需要重新查询,因为不知道在内部是不是有变动单份营养素。 + // 有变动,退出不刷新,再次进入还是能看到旧的;但是返回就刷新对于只是浏览数据不友好。 + // 因此,详情页会有一个是否被异动的标志,返回true则重新查询;否则就不更新 + if (value != null && value == true) { + setState(() { + dishItems.clear(); + currentPage = 1; + }); + _loadDishData(); + } + }); + }, + // 长按点击弹窗提示是否删除 + onLongPress: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("是否删除该菜品?"), + content: Text("菜品名称:${dish.dishName}"), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, false); + }, + child: const Text("取消"), + ), + TextButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text("确认"), + ), + ], + ); + }, + ).then((value) async { + if (value != null && value) { + try { + await _dbHelper.deleteDishById(dish.dishId); + + // 删除后重新查询 + setState(() { + dishItems.clear(); + currentPage = 1; + }); + _loadDishData(); + } catch (e) { + if (!mounted) return; + // ignore: use_build_context_synchronously + commonExceptionDialog(context, "异常提醒", e.toString()); + } + } + }); + }, + ), + + /// Wrap最小高度48吧,调不了,在外面限制一个box高度 + // 2024-03-10 分类和餐次各占一行吧,但这样列表太高了,不好看 + SizedBox( + height: 28.sp, + child: Wrap( + // spacing: 5, + alignment: WrapAlignment.spaceAround, + children: [ + ...[ + // 如果标签很多,只显示4个,然后整体剩下的用一个数字代替 + ...initCates + .map((mood) { + return buildSmallButtonTag( + mood, + bgColor: Colors.lightBlue, + labelTextSize: 12, + ); + }) + .toList() + .sublist( + 0, + initCates.length > 4 ? 4 : initCates.length, + ), + ], + if (initCates.length > 4) + buildSmallButtonTag( + '+${initCates.length - 4}', + bgColor: Colors.grey, + labelTextSize: 12, + ), + ], + ), + ), + SizedBox( + height: 28.sp, + child: Wrap( + // spacing: 5, + alignment: WrapAlignment.spaceAround, + children: [ + ...[ + // 如果标签很多,只显示4个,然后整体剩下的用一个数字代替 + ...initTags + .map((tag) { + return buildSmallButtonTag( + tag, + bgColor: Colors.lightGreen, + labelTextSize: 12, + ); + }) + .toList() + .sublist( + 0, + initTags.length > 4 ? 4 : initTags.length, + ), + ], + if (initTags.length > 4) + buildSmallButtonTag( + '+${initTags.length - 4}', + bgColor: Colors.grey, + labelTextSize: 12, + ), + ], + ), + ), + SizedBox(height: 20.sp) + ], + ), + ); + } + }, + controller: scrollController, + ); + } +} diff --git a/lib/views/random_dish/dish_modify.dart b/lib/views/random_dish/dish_modify.dart new file mode 100644 index 0000000..912a179 --- /dev/null +++ b/lib/views/random_dish/dish_modify.dart @@ -0,0 +1,456 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:form_builder_file_picker/form_builder_file_picker.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:uuid/uuid.dart'; + +import '../../common/components/tool_widget.dart'; +import '../../common/constants.dart'; +import '../../common/db_tools/db_helper.dart'; +import '../../common/utils/tools.dart'; +import '../../models/dish.dart'; + +class DishModify extends StatefulWidget { + // 新增菜品不会传,但修改菜品会传 + final Dish? dish; + + const DishModify({this.dish, super.key}); + + @override + State createState() => _DishModifyState(); +} + +class _DishModifyState extends State { + final DBHelper _dbHelper = DBHelper(); + + // 菜品基础信息的formbuilder的表单key + final _dishFormKey = GlobalKey(); + // 菜品分类下拉多选弹窗的key + final _dishTagsSelectKey = GlobalKey(); + // 菜品餐次下拉多选弹窗的key + final _dishCatesSelectKey = GlobalKey(); + + // 如果有菜品示意图,要显示 + List initImages = []; + // 2024-03-12 上面那个用在本地上传文件的图片初始化,这个用在网络图片的地址展示 + List initNetworkImages = []; + + // 被选中的菜品的分类和餐次类型 + List selectedDishTags = []; + List selectedDishCates = []; + + // 用户头像路径 + String? _recipePicturePath; + + // 2024-06-26 数据库中已经存在+预设的分类,修改时才好匹配上 + List allDishCates = []; + List allDishTags = []; + + @override + void initState() { + setState(() { + getAllDishCatesTags(); + }); + + super.initState(); + + // (不能放在下面那个callback中,会在表单初始化完成之后再赋值,那就没有意义了) + setState(() { + initSelectedTagCate(); + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + // 如果有传表单的初始对象值,就显示该值 + if (widget.dish != null) { + // 注意,dish不能直接显示到form表单中,要转为map + setState(() { + // 因为表单中图片栏位的name是images而不是photos,这里赋值时匹配不上会忽略,就不会有 + // type 'String' is not a subtype of type 'List?' of 'value' 错误了。 + // 而实际的图片地址初始化,在上面已经赋值过了 + _dishFormKey.currentState?.patchValue(widget.dish!.toMap()); + }); + } + }); + } + + // 初始化所有的分类和餐次类型(预设的+数据库中的) + initSelectedTagCate() async { + // 需要等拿到所有分类之后,再渲染 + await getAllDishCatesTags(); + + if (widget.dish != null) { + // 如果有菜品有图片,则显示图片 + if (widget.dish!.photos != null && widget.dish!.photos != "") { + // 2024-03-12 获取所有图片地址,再根据前缀拆为网络图片和本地图片 + List imageUrls = widget.dish!.photos!.split(','); + + // 如果本身就是空字符串,直接返回空平台文件数组 + if (widget.dish!.photos!.trim().isEmpty || imageUrls.isEmpty) { + initImages = []; + return; + } + + // 如果有图片,网络图片部分直接过滤出来,直接显示地址即可 + initNetworkImages = imageUrls + .where((e) => e.startsWith("http") || e.startsWith("https")) + .toList(); + + // 剩下的部分就转为平台文件格式,配合组件可以预览图片 + List restImages = imageUrls + .where((e) => !(e.startsWith("http") || e.startsWith("https"))) + .toList(); + + for (var imageUrl in restImages) { + PlatformFile file = PlatformFile( + name: imageUrl, + path: imageUrl, + size: 32, // 假设图片地址即为文件路径 + ); + initImages.add(file); + } + } + + // 如果有分类,显示被选中的分类 + if (widget.dish!.tags != null && widget.dish!.tags != "") { + selectedDishTags = genSelectedCusLabelOptions( + widget.dish!.tags!, + allDishTags, + ); + } + // 如果有餐次,显示被选中的餐次 + if (widget.dish!.mealCategories != null && + widget.dish!.mealCategories != "") { + selectedDishCates = genSelectedCusLabelOptions( + widget.dish!.mealCategories!, + allDishCates, + ); + } + + // 2024-03-22 如果有菜谱图片,也显示 + if (widget.dish!.recipePicture != null && + widget.dish!.recipePicture != "") { + _recipePicturePath = widget.dish!.recipePicture!; + } + } + } + + // 获取数据库中+预设的所有分类和标签 + getAllDishCatesTags() async { + CusDataResult temp = await _dbHelper.queryDishList( + page: 1, + pageSize: 10000, // 应该查询所有 + ); + + var newData = temp.data as List; + + List tempCates = []; + List tempTags = []; + + for (var e in newData) { + var a = (e.mealCategories?.split(",")); + var b = (e.tags?.split(",")); + // 合并两个列表, Set()字面量去除重复,然后转回List + tempCates = {...tempCates, ...?a}.toSet().toList(); + tempTags = {...tempTags, ...?b}.toSet().toList(); + } + + // 移除已经存在的分类和标签 + for (var e in dishCateOptions) { + if (tempCates.contains(e.cnLabel)) { + tempCates.remove(e.cnLabel); + } + } + for (var e in dishTagOptions) { + if (tempTags.contains(e.cnLabel)) { + tempTags.remove(e.cnLabel); + } + } + + // 再将标签和分类字符串简单转为对象列表 + allDishCates = tempCates + .map((e) => CusLabel(value: e, enLabel: e, cnLabel: e)) + .toList(); + allDishTags = tempTags + .map((e) => CusLabel(value: e, enLabel: e, cnLabel: e)) + .toList(); + + // 最后合并预设的和导入时存入数据库中的 + setState(() { + allDishCates = {...dishCateOptions, ...allDishCates}.toList(); + allDishTags = {...dishTagOptions, ...allDishTags}.toList(); + }); + } + + // 将菜品信息保存到数据库中 + _saveDishInfoToDb() async { + var flag1 = _dishTagsSelectKey.currentState?.validate(); + var flag2 = _dishCatesSelectKey.currentState?.validate(); + var flag3 = _dishFormKey.currentState!.saveAndValidate(); + + // 如果表单验证都通过了,保存数据到数据库,并返回上一页 + if (flag1! && flag2! && flag3) { + var temp = _dishFormKey.currentState; + // ???2023-10-15 这里取值是不是刻意直接使用temp而不是按照每个栏位名称呢 + + // 2023-03-12 图片部分要网络图片和本地图片联合起来 + String? netImage = temp?.fields["network_images"]?.value; + List locImages = temp?.fields['images']?.value ?? []; + String locImage = locImages.map((e) => e.path).join(","); + + var totalImage = [ + if (netImage != null) ...netImage.split(",").where((e) => e != ""), + ...locImage.split(",").where((e) => e != "") + ]; + + Dish dish = Dish( + dishId: const Uuid().v1(), + dishName: temp?.fields['dish_name']?.value, + description: temp?.fields['description']?.value, + recipe: temp?.fields['recipe']?.value, + tags: selectedDishTags.isNotEmpty + ? selectedDishTags + .map((opt) => (opt as CusLabel).value) + .toList() + .join(',') + : null, + mealCategories: selectedDishCates.isNotEmpty + ? (selectedDishCates) + .map((opt) => (opt as CusLabel).value) + .toList() + .join(',') + : null, + photos: totalImage.join(","), + recipePicture: _recipePicturePath, + ); + + try { + // 有旧菜品信息就是修改;没有就是新增 + if (widget.dish != null) { + // 修改的话要保留原本的编号 + dish.dishId = widget.dish!.dishId; + + await _dbHelper.updateDish(dish); + } else { + await _dbHelper.insertDishList([dish]); + } + + if (mounted) { + // 2023-12-21 不报错就当作修改成功,直接返回 + Navigator.pop(context, true); + } + } catch (e) { + if (!mounted) return; + // 2023-12-21 插入失败上面弹窗显示 + commonExceptionDialog(context, "异常提醒", e.toString()); + } + } + } + +// 选择图片来源 + Future _pickImage(ImageSource source) async { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: source); + if (pickedFile != null) { + setState(() { + _recipePicturePath = pickedFile.path; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('${widget.dish != null ? "修改" : "新增"}菜品信息'), + actions: [ + IconButton( + onPressed: _saveDishInfoToDb, + icon: const Icon(Icons.save), + ) + ], + ), + body: ListView( + children: [ + Padding( + padding: EdgeInsets.all(10.sp), + child: SingleChildScrollView( + child: FormBuilder( + key: _dishFormKey, + initialValue: widget.dish != null ? widget.dish!.toMap() : {}, + child: Column( + children: [ + ...buildDishModifyFormColumns( + context, + initImages: initImages.isEmpty ? null : initImages, + ), + ], + ), + ), + ), + ), + SizedBox(height: 20.sp), + ], + ), + ); + } + + // 构建菜品编辑表单栏位 + buildDishModifyFormColumns( + BuildContext context, { + List? initImages, + }) { + return [ + cusFormBuilerTextField( + "dish_name", + labelText: '*菜品名称', + validator: FormBuilderValidators.required(), + ), + cusFormBuilerTextField("description", labelText: '菜品简介', maxLines: 3), + const SizedBox(height: 10), + // 菜品标签(多选) + buildModifyMultiSelectDialogField( + context, + items: allDishTags, + key: _dishTagsSelectKey, + initialValue: selectedDishTags, + labelText: "*菜品标签", + validator: FormBuilderValidators.required(), + onConfirm: (results) { + selectedDishTags = results; + // 从多选框弹窗回来不聚焦 + FocusScope.of(context).requestFocus(FocusNode()); + }, + ), + const SizedBox(height: 10), + // 菜品餐次(多选) + buildModifyMultiSelectDialogField( + context, + items: allDishCates, + key: _dishCatesSelectKey, + initialValue: selectedDishCates, + labelText: "*菜品餐次", + validator: FormBuilderValidators.required(), + onConfirm: (results) { + selectedDishCates = results; + // 从多选框弹窗回来不聚焦 + FocusScope.of(context).requestFocus(FocusNode()); + }, + ), + cusFormBuilerTextField( + "videos", + labelText: '视频地址(推荐使用单个网址)', + maxLines: 5, + valueFontSize: 14, + ), + cusFormBuilerTextField( + "network_images", + labelText: '网络图片地址(用英文逗号分割, 且结尾不要有逗号)', + maxLines: 5, + initialValue: initNetworkImages.join(","), + valueFontSize: 14, + ), + SizedBox(height: 10.sp), + // 上传菜品图片(静态图或者gif) + FormBuilderFilePicker( + /// 2024-03-10 注意,这个图片上传的name命名很重要,【不能】和model中的photos属性一样。 + /// 因为在修改菜品时,会patchValue,而此处需要的是List?,同名了传来的就会是String, + /// 则报错:type 'String' is not a subtype of type 'List?' of 'value' + name: 'images', + decoration: const InputDecoration( + labelText: "菜品图片", + // 设置透明底色 + filled: true, + fillColor: Colors.transparent, + ), + initialValue: initImages, + maxFiles: null, + allowMultiple: true, + previewImages: true, + // onChanged: (val) => debugPrint(val.toString()), + typeSelectors: const [ + TypeSelector( + type: FileType.image, + selector: Row( + children: [ + Icon(Icons.file_upload), + Text("上传菜品图片"), + ], + ), + ) + ], + customTypeViewerBuilder: (children) => Row( + mainAxisAlignment: MainAxisAlignment.end, + children: children, + ), + // onFileLoading: (val) => debugPrint(val.toString()), + ), + + cusFormBuilerTextField( + "recipe", + labelText: '菜谱', + maxLines: 10, + ), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + TextButton( + onPressed: () { + FocusScope.of(context).unfocus(); + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text( + "菜谱图片上传方式", + style: TextStyle(fontSize: 20.sp), + ), + content: const Text("注意,仅支持单张图片!"), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _pickImage(ImageSource.camera); + }, + child: const Text("拍照"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _pickImage(ImageSource.gallery); + }, + child: const Text("相册"), + ), + ], + ); + }, + ); + }, + child: Text( + "上传菜谱图片", + style: TextStyle(fontSize: 15.sp), + ), + ), + TextButton( + onPressed: _recipePicturePath != null + ? () { + setState(() { + _recipePicturePath = null; + }); + } + : null, + child: Text( + "移除菜谱图片", + style: TextStyle(fontSize: 15.sp), + ), + ), + ], + ), + + if (_recipePicturePath != null && _recipePicturePath!.isNotEmpty) + buildClickImageDialog(context, _recipePicturePath!) + ]; + } +} diff --git a/lib/views/random_dish/dish_wheel_index.dart b/lib/views/random_dish/dish_wheel_index.dart new file mode 100644 index 0000000..2f91728 --- /dev/null +++ b/lib/views/random_dish/dish_wheel_index.dart @@ -0,0 +1,532 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_fortune_wheel/flutter_fortune_wheel.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../common/constants.dart'; +import '../../common/db_tools/db_helper.dart'; +import '../../common/utils/tools.dart'; +import '../../models/dish.dart'; + +import 'dish_detail.dart'; +import 'dish_list.dart'; + +/// 随机从数据库中获取10条食物数据,然后这里转盘再选一次 +class DishWheelIndex extends StatefulWidget { + const DishWheelIndex({Key? key}) : super(key: key); + + @override + State createState() => _DishWheelIndexState(); +} + +// TickerProviderStateMixin 允许一个小部件充当同一小部件​​树中多个“AnimationController”实例的“TickerProvider”。 +class _DishWheelIndexState extends State + with TickerProviderStateMixin { + // 当前是哪个时间段(餐次字符串,2024-06-27 仅展示,不作为参数) + String currentMeal = ""; + // 2024-06-27 当前的餐次分类,上面那个根据时间来获取,不让修改; + // 这个是获取到之后,可以手动修改,用着查询的参数 + String mealCate = ""; + + // 随机的10条食物列表和食物名称(名称用来显示,食物用来跳转) + List randomDishes = []; + List randomDishLabels = []; + + final DBHelper _dbHelper = DBHelper(); + + // 转盘流控制器 + StreamController streamController = StreamController(); + + // 转盘选中的值 + Dish? selectedValue; + // 转盘是否旋转中,在就不显示选中的值 + var isWheelSpin = false; + + // 转盘下方提示语。刚打开app应该什么都没有,开始旋转显示旋转,旋转结束显示结果 + String selectedNote = ""; + + // 2024-06-27 数据库中已经存在+预设的分类,修改时才好匹配上 + List allDishCates = []; + + @override + void initState() { + super.initState(); + + setState(() { + initCatesAndRondomDishes(); + }); + } + + // 初始化所有菜品分类信息 + initCatesAndRondomDishes() async { + // 一定在查询菜品之前,获得所有的菜品分类 + await getAllDishCatesTags(); + + setState(() { + currentMeal = getTimePeriod(); + mealCate = getTimePeriod(); + + // 默认启动就根据当前时间查询符合餐次的菜品,构建转盘数据 + getRondomDishes(); + }); + } + + // 获取数据库中+预设的所有分类和标签 + getAllDishCatesTags() async { + CusDataResult temp = await _dbHelper.queryDishList( + page: 1, + pageSize: 10000, // 应该查询所有 + ); + + var newData = temp.data as List; + + List tempCates = []; + + for (var e in newData) { + var a = (e.mealCategories?.split(",")); + // 合并两个列表, Set()字面量去除重复,然后转回List + tempCates = {...tempCates, ...?a}.toSet().toList(); + } + + // 移除已经存在的分类和标签 + for (var e in dishCateOptions) { + if (tempCates.contains(e.cnLabel)) { + tempCates.remove(e.cnLabel); + } + } + + // 再将标签和分类字符串简单转为对象列表 + allDishCates = tempCates + .map((e) => CusLabel(value: e, enLabel: e, cnLabel: e)) + .toList(); + + // 最后合并预设的和导入时存入数据库中的 + setState(() { + allDishCates = {...dishCateOptions, ...allDishCates}.toList(); + }); + } + + // 随机生成10条食物 + // 点击按钮才会调用这个函数,每次 + getRondomDishes() async { + randomDishes = await _dbHelper.queryRandomDishList( + size: 10, + cate: mealCate, + ); + + setState(() { + randomDishLabels = randomDishes.map((e) => e.dishName).toList(); + + // 不管是刷新页面还是重新生成数据,都清空之前选择的菜品和提示语 + selectedNote = ''; + selectedValue = null; + + // 2024-03-23 如果指定餐次的预设菜品为空或者不足2个,关闭转盘的监听。取值会单独处理 + if (randomDishLabels.isEmpty || randomDishLabels.length < 2) { + streamController.close(); + } + // 2024-03-23 如果只有一条,则预选上那一条 + if (randomDishLabels.isNotEmpty && randomDishLabels.length < 2) { + selectedValue = randomDishes.first; + } else if (randomDishLabels.isNotEmpty && randomDishLabels.length >= 2) { + // 如果预设菜单列表不为空,每次从无转盘切换到有转盘,不更新控制器,就是用旧的控制器来取新转盘的值, + // fortune_wheel 组件就会报错:Bad state: Stream has already been listened to. + // 注意,需要判断是否已经被关闭了,如果没有被关闭则不需要重新声明 + if (streamController.isClosed) { + streamController = StreamController(); + } + } + }); + } + + // 刷新页面,就重新获取餐次标签,和清除转盘(菜品列表为空就不显示了) + refreshPage() { + setState(() { + currentMeal = getTimePeriod(); + mealCate = getTimePeriod(); + getRondomDishes(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('今天吃什么?'), + actions: buildAppBarActions(), + ), + body: Container( + decoration: BoxDecoration( + color: Colors.pink.withOpacity(0.1), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 10.sp), + Expanded(flex: 1, child: buildMealCateAndRondomButtonRow()), + // const SizedBox(height: 20), + // 默认是没有参数列表的,但点击随机生成之后,就会有,有了之后才显示转盘和开始按钮 + Expanded(flex: 3, child: buildFortuneWheelArea()), + buildSelectedArea(), + SizedBox(height: 20.sp), + ], + ), + ), + ); + } + + /// appbar的功能按钮 + buildAppBarActions() { + return [ + IconButton( + onPressed: isWheelSpin ? null : refreshPage, + icon: const Icon(Icons.refresh), + ), + IconButton( + onPressed: isWheelSpin + ? null + : () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const DishList(), + ), + ); + }, + icon: const Icon(Icons.menu), + ), + // 这里可展示说明 + IconButton( + onPressed: isWheelSpin + ? null + : () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("使用说明"), + content: RichText( + text: TextSpan( + children: [ + TextSpan( + text: "点击转盘即可开始旋转", + style: TextStyle( + fontSize: 16.sp, + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: "\n\n点击【餐次下拉选择框】可选择指定餐次;再点击【随机10款菜品】更新预选列表", + style: TextStyle( + fontSize: 12.sp, + color: Colors.black, + ), + ), + TextSpan( + text: "\n\n点击【随机10款菜品】按钮可更新预选列表,不足10个就全部显示", + style: TextStyle( + fontSize: 12.sp, + color: Colors.black, + ), + ), + TextSpan( + text: "\n\n点击下方随机结果的菜品名称可跳转到该菜品详情页", + style: TextStyle( + fontSize: 12.sp, + color: Colors.black, + ), + ), + TextSpan( + text: "\n\n顶部【刷新】图标按钮会根据当前系统时间获取并显示餐次标签", + style: TextStyle( + fontSize: 12.sp, + color: Colors.black, + ), + ), + TextSpan( + text: "\n\n顶部【菜单】图标按钮可以进入菜品列表管理页面", + style: TextStyle( + fontSize: 12.sp, + color: Colors.black, + ), + ), + TextSpan( + text: "\n\n虽然“菜品列表”中网络图片会进行缓存,但也注意流量消耗。", + style: TextStyle( + fontSize: 16.sp, + color: Colors.blue, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text("确认"), + ), + ], + ); + }, + ); + }, + icon: const Icon(Icons.info_outlined), + ) + ]; + } + + /// 餐次标签和随机生成菜品按钮区域 + buildMealCateAndRondomButtonRow() { + return Card( + elevation: 3, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + flex: 3, + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + text: '现在是 ', + style: TextStyle(color: Colors.black, fontSize: 16.sp), + children: [ + TextSpan( + text: currentMeal, + style: TextStyle( + color: Colors.blue, + fontWeight: FontWeight.bold, + fontSize: 24.sp, + ), + ), + TextSpan( + text: ' 时间!', + style: TextStyle(color: Colors.black, fontSize: 16.sp), + ), + ], + ), + ), + ), + Expanded( + flex: 3, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildMateCatesList(), + SizedBox( + width: 144.sp, + child: FilledButton( + onPressed: isWheelSpin + ? null + : () { + getRondomDishes(); + }, + child: const Text("随机10款菜品"), + ), + ) + ], + ), + ) + ], + ), + ); + } + + // 可以手动切换餐次分类 + _buildMateCatesList() { + return DropdownMenu( + width: 144.sp, + menuHeight: 300.sp, + initialSelection: mealCate, + // 限制下拉框高度更小点 + inputDecorationTheme: InputDecorationTheme( + isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: 10.sp), + constraints: BoxConstraints.tight(Size.fromHeight(40.sp)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20.sp), + ), + ), + // 2024-03-23 转盘在旋转的时候,不可点击切换餐次 + enabled: !isWheelSpin, + onSelected: (String? value) { + setState(() { + // 2024-06-27 手动切换了分类,不改变当前用餐时间的预设分类 + mealCate = value!; + // 2024-03-23 切换餐次就一并更新转盘预选列表 + getRondomDishes(); + }); + }, + // trailingIcon: Icon(Icons.arrow_drop_down_outlined, size: 14.sp), + dropdownMenuEntries: + allDishCates.map>((CusLabel value) { + return DropdownMenuEntry( + value: value.cnLabel, + label: value.cnLabel, + ); + }).toList(), + ); + } + + /// 转盘主体区域 + buildFortuneWheelArea() { + if (randomDishLabels.isNotEmpty && randomDishLabels.length < 2) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("该餐次菜品仅1个", style: TextStyle(fontSize: 18.sp)), + Text("${randomDishLabels.firstOrNull}"), + ], + ); + } + return Column( + children: [ + (randomDishLabels.isNotEmpty && randomDishLabels.length > 1) + ? Expanded( + child: SizedBox( + width: 0.95.sw, + height: 0.95.sw, + child: Padding( + padding: const EdgeInsets.only(top: 30), + child: GestureDetector( + // 实际上是先就确定了被选中的值,然后在转盘停下来时指针指向它。 + // 所以要在转盘的动画结束后,才显示被选中的值 + onTap: () { + setState(() { + isWheelSpin = true; + + selectedNote = "看我转转转~"; + // 获取随机的菜品信息 + var index = + Fortune.randomInt(0, randomDishLabels.length); + selectedValue = randomDishes[index]; + streamController.add(index); + }); + }, + child: Column( + children: [ + Expanded( + // ???这里有个问题现象,就是转盘的值列表从有值转为空值,再转为有值会报错: + // Bad state: Stream has already been listened to. + // 实际是不是这样不清楚,但先保证db中每个餐次分类都有数据 + child: FortuneWheel( + animateFirst: false, + // 被选中的值索引 + selected: streamController.stream, + // 指示器样式 + indicators: const [ + FortuneIndicator( + // <-- changing the position of the indicator + alignment: Alignment.topCenter, + child: TriangleIndicator( + // <-- changing the color of the indicator + color: Colors.amber, + // <-- changing the width of the indicator + width: 30.0, + // <-- changing the height of the indicator + height: 15.0, + // <-- changing the elevation of the indicator + elevation: 10, + ), + ), + ], + // 条目的值和样式 + items: [ + for (var it in randomDishLabels) + FortuneItem( + child: Text( + it.length > 8 + ? "${it.substring(0, 8)}..." + : it, + ), + // style: const FortuneItemStyle( + // // <-- custom circle slice fill color + // color: Colors.red, + // // <-- custom circle slice stroke color + // borderColor: Colors.green, + // // <-- custom circle slice stroke width + // borderWidth: 3, + // ), + ), + ], + onAnimationStart: () { + debugPrint("动画开始了……"); + }, + onAnimationEnd: () { + setState(() { + isWheelSpin = false; + selectedNote = "最终选中了: "; + }); + debugPrint("动画停止了……"); + }, + ), + ), + ], + ), + ), + ), + ), + ) + : Container(), + if (randomDishLabels.isEmpty) + Padding( + padding: EdgeInsets.all(20.sp), + child: Text( + "该餐次暂无菜品", + style: TextStyle(fontSize: 18.sp), + ), + ), + SizedBox(height: 10.sp), + Text(selectedNote), + ], + ); + } + + /// 选择结果区域 + buildSelectedArea() { + return (selectedValue != null && !isWheelSpin) + ? SizedBox( + height: 80.sp, + child: Card( + elevation: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ListTile( + // 菜品名称 + title: Text( + "${selectedValue?.dishName}", + maxLines: 2, + softWrap: true, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18, + color: Theme.of(context).primaryColor, + ), + textAlign: TextAlign.center, + ), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + DishDetail(dishItem: selectedValue!), + ), + ); + }, + ), + ], + ), + ), + ) + : SizedBox(height: 80.sp); + } +} diff --git a/lib/views/user_and_settings/backup_and_restore/index.dart b/lib/views/user_and_settings/backup_and_restore/index.dart new file mode 100644 index 0000000..ee8e797 --- /dev/null +++ b/lib/views/user_and_settings/backup_and_restore/index.dart @@ -0,0 +1,487 @@ +// ignore_for_file: avoid_print, constant_identifier_names + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:path/path.dart' as p; +import 'package:archive/archive_io.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../../common/components/tool_widget.dart'; +import '../../../common/db_tools/db_helper.dart'; +import '../../../common/utils/tools.dart'; +import '../../../models/brief_accounting_state.dart'; +import '../../../models/dish.dart'; +import '../../../models/llm_chat_state.dart'; +import '../../../models/llm_text2image_state.dart'; + +/// +/// 2023-12-26 备份恢复还可以优化,就暂时不做 +/// +/// +// 全量备份导出的文件的前缀(_时间戳.zip) +const ZIP_FILE_PREFIX = "智能轻生活全量数据备份_"; +// 导出文件要压缩,临时存放的地址 +const ZIP_TEMP_DIR_AT_EXPORT = "temp_zip"; +const ZIP_TEMP_DIR_AT_UNZIP = "temp_de_zip"; +const ZIP_TEMP_DIR_AT_RESTORE = "temp_auto_zip"; + +class BackupAndRestore extends StatefulWidget { + const BackupAndRestore({super.key}); + + @override + State createState() => _BackupAndRestoreState(); +} + +class _BackupAndRestoreState extends State { + final DBHelper _dbHelper = DBHelper(); + + bool isLoading = false; + + // 是否获得了存储权限(没获得就无法备份恢复) + bool isPermissionGranted = false; + + // 2024-06-05 测试用,z50u没法调试,build之后在页面显示权限结果 + String tempPermMsg = ""; + + String note = """'全量备份' 是把应用本地数据库中的所有数据导出保存在本地,包括用智能助手的对话历史、账单列表、菜品列表。 +\n'覆盖恢复' 是把 '全量备份' 导出的压缩包,重新导入到应用中,覆盖应用本地数据库中的所有数据。 +"""; + + @override + void initState() { + super.initState(); + + _getPermission(); + } + + _getPermission() async { + bool flag = await requestPermission(); + setState(() { + isPermissionGranted = flag; + }); + } + + /// + /// 全量备份:导出db中所有的数据 + /// + /// 1. 询问是否有范围内部存储的权限 + /// 2. 用户选择要导出的文件存放的位置 + /// 3. 处理备份 + /// 3.1 先创建一个内部临时保存备份文件的地址 + /// 不直接保存到用户指定地址,是避免万一导出很久还没完用户就删掉了,整个过程就无法控制 + /// 3.2 dbhelper导出table数据为各个json文件 + /// 3.3 将这些json文件压缩到各个创建的内部临时地址 + /// 3.4 将临时地址的压缩文件,复制到用户指定的文件 + /// 3.5 删除临时地址的压缩文件 + /// + exportAllData() async { + // 用户没有授权,简单提示一下 + if (!mounted) return; + if (!isPermissionGranted) { + showSnackMessage( + context, + "用户已禁止访问内部存储,无法进行json文件导入。\n如需启用,请到应用的权限管理中授权读写手机存储。", + ); + return; + } + + // 用户选择指定文件夹 + String? selectedDirectory = await FilePicker.platform.getDirectoryPath(); + // 如果有选中文件夹,执行导出数据库的json文件,并添加到压缩档。 + if (selectedDirectory != null) { + if (isLoading) return; + + setState(() { + isLoading = true; + }); + + // 获取应用文档目录路径 + Directory appDocDir = await getApplicationDocumentsDirectory(); + // 临时存放zip文件的路径 + var tempZipDir = await Directory( + p.join(appDocDir.path, ZIP_TEMP_DIR_AT_EXPORT), + ).create(); + // zip 文件的名称 + String zipName = + "$ZIP_FILE_PREFIX${DateTime.now().millisecondsSinceEpoch}.zip"; + + try { + // 执行将db数据导出到临时json路径和构建临时zip文件(???应该有错误检查) + await _backupDbData(zipName, tempZipDir.path); + + // 移动临时文件到用户选择的位置 + File sourceFile = File(p.join(tempZipDir.path, zipName)); + File destinationFile = File(p.join(selectedDirectory, zipName)); + + // 如果目标文件已经存在,则先删除 + if (destinationFile.existsSync()) { + destinationFile.deleteSync(); + } + + // 把文件从缓存的位置放到用户选择的位置 + sourceFile.copySync(p.join(selectedDirectory, zipName)); + print('文件已成功复制到:${p.join(selectedDirectory, zipName)}'); + + // 删除临时zip文件 + if (sourceFile.existsSync()) { + // 如果目标文件已经存在,则先删除 + sourceFile.deleteSync(); + } + + setState(() { + isLoading = false; + }); + + if (!mounted) return; + showSnackMessage( + context, + "已经保存到$selectedDirectory", + backgroundColor: Colors.green, + ); + } catch (e) { + print('保存操作出现错误: $e'); + setState(() { + isLoading = false; + }); + } + } else { + print('保存操作已取消'); + return; + } + } + + // 备份db中数据到指定文件夹 + Future _backupDbData( + // 会把所有json文件打包成1个压缩包,这是压缩包的名称 + String zipName, + // 在构建zip文件时,会先放到临时文件夹,构建完成后才复制到用户指定的路径去 + String tempZipPath, + ) async { + // 等到所有文件导出,都默认放在同一个文件夹下,所以就不用返回路径了 + await _dbHelper.exportDatabase(); + + // 创建或检索压缩包临时存放的文件夹 + var tempZipDir = await Directory(tempZipPath).create(); + + // 获取临时文件夹目录(在导出函数中是固定了的,所以这里也直接取就好) + Directory appDocDir = await getApplicationDocumentsDirectory(); + String tempJsonsPath = p.join(appDocDir.path, DB_EXPORT_DIR); + // 临时存放所有json文件的文件夹 + Directory tempDirectory = Directory(tempJsonsPath); + + // 创建压缩文件 + final encoder = ZipFileEncoder(); + encoder.create(p.join(tempZipDir.path, zipName)); + + // 遍历临时文件夹中的所有文件和子文件夹,并将它们添加到压缩文件中 + await for (FileSystemEntity entity in tempDirectory.list(recursive: true)) { + if (entity is File) { + encoder.addFile(entity); + } else if (entity is Directory) { + encoder.addDirectory(entity); + } + } + + // 完成并关闭压缩文件 + encoder.close(); + + // 压缩完成后,清空临时json文件夹中文件 + await _deleteFilesInDirectory(tempJsonsPath); + } + +// 删除指定文件夹下所有文件 + Future _deleteFilesInDirectory(String directoryPath) async { + final directory = Directory(directoryPath); + if (await directory.exists()) { + await for (var file in directory.list()) { + if (file is File) { + await file.delete(); + } + } + } + } + + /// + /// 2023-12-11 恢复的话,简单需要导出时同名的zip压缩包 + /// + /// 1. 获取用户选择的压缩文件 + /// 2. 判断选中的文件是否符合导出的文件格式(匹配前缀和后缀,不符合不做任何操作) + /// 3. 处理导入过程 + /// 3.1 先解压压缩包,读取json文件 + /// 3.2 先将数据库中的数据备份到临时文件夹中(避免恢复失败数据就找不回来了) + /// 3.3 临时备份完成,删除数据库,再新建数据库(插入时会自动新建) + /// 3.4 将json文件依次导入数据库 + /// 3.5 json文件导入成功,则删除临时备份文件 + /// + Future restoreDataFromBackup() async { + FilePickerResult? result = + await FilePicker.platform.pickFiles(allowMultiple: false); + if (result != null) { + if (isLoading) return; + + setState(() { + isLoading = true; + }); + + // 不允许多选,理论就是第一个文件,且不为空 + File file = File(result.files.first.path!); + + print("获取的上传zip文件路径:${p.basename(file.path)}"); + print("获取的上传zip文件路径 result: $result"); + + // 这个判断虽然不准确,但先这样 + if (p.basename(file.path).toUpperCase().startsWith(ZIP_FILE_PREFIX) && + p.basename(file.path).toLowerCase().endsWith('.zip')) { + try { + // 等待解压完成 + // 遍历解压后的文件,取得里面的文件(可能会有嵌套文件夹和其他格式的文件,不过这里没有) + List jsonFiles = Directory(await _unzipFile(file.path)) + .listSync() + .where( + (entity) => entity is File && entity.path.endsWith('.json')) + .map((entity) => entity as File) + .toList(); + + print("解压得到的jsonFiles:$jsonFiles"); + + /// 删除前可以先备份一下到临时文件,避免出错后完成无法使用(最多确认恢复成功之后再删除就好了) + + // 获取应用文档目录路径 + Directory appDocDir = await getApplicationDocumentsDirectory(); + // 临时存放zip文件的路径 + var tempZipDir = await Directory( + p.join(appDocDir.path, ZIP_TEMP_DIR_AT_RESTORE), + ).create(); + // zip 文件的名称 + String zipName = + "$ZIP_FILE_PREFIX${DateTime.now().millisecondsSinceEpoch}.zip"; + // 执行将db数据导出到临时json路径和构建临时zip文件(???应该有错误检查) + await _backupDbData(zipName, tempZipDir.path); + + // 恢复旧数据之前,删除现有数据库 + await _dbHelper.deleteDB(); + + // 保存恢复的数据(应该检查的???) + await _saveJsonFileDataToDb(jsonFiles); + + // 成功恢复后,删除临时备份的zip + File sourceFile = File(p.join(tempZipDir.path, zipName)); + // 删除临时zip文件 + if (sourceFile.existsSync()) { + // 如果目标文件已经存在,则先删除 + sourceFile.deleteSync(); + } + + setState(() { + isLoading = false; + }); + + if (!mounted) return; + showSnackMessage( + context, + "原有数据已删除,备份数据已恢复。", + backgroundColor: Colors.green, + ); + } catch (e) { + // 弹出报错提示框 + if (!mounted) return; + + commonHintDialog( + context, + "导入json文件出错", + "文件名称:\n${file.path}\n\n错误信息:\n${e.toString()}", + ); + + setState(() { + isLoading = false; + }); + // 中止操作 + return; + } + } else { + if (!mounted) return; + showSnackMessage( + context, + "用于恢复的备份文件格式不对,恢复已取消。", + backgroundColor: Colors.red, + ); + } + // 这个判断不准确,但先这样 + setState(() { + isLoading = false; + }); + } else { + // User canceled the picker + return; + } + } + + // 解压zip文件 + Future _unzipFile(String zipFilePath) async { + try { + // 获取临时目录路径 + Directory tempDir = await getTemporaryDirectory(); + + // 创建或检索压缩包临时存放的文件夹 + String tempPath = (await Directory( + p.join(tempDir.path, ZIP_TEMP_DIR_AT_UNZIP), + ).create()) + .path; + + // 读取zip文件 + File file = File(zipFilePath); + List bytes = file.readAsBytesSync(); + + // 解压缩 + Archive archive = ZipDecoder().decodeBytes(bytes); + for (ArchiveFile file in archive) { + String filename = '$tempPath/${file.name}'; + if (file.isFile) { + File outFile = File(filename); + outFile = await outFile.create(recursive: true); + await outFile.writeAsBytes(file.content); + + print("解压时的outFile:$outFile"); + } else { + Directory dir = Directory(filename); + await dir.create(recursive: true); + } + } + print('解压完成'); + + return tempPath; + } catch (e) { + print('解压失败: $e'); + throw Exception(e); + } + } + + // 将恢复的json数据存入db中 + _saveJsonFileDataToDb(List jsonFiles) async { + // 解压之后获取到所有的json文件,逐个添加到数据库,会先清空数据库的数据 + for (File file in jsonFiles) { + print("执行json保存到db时对应的json文件:${file.path}"); + + String jsonData = await file.readAsString(); + // db导出时json文件是列表 + List jsonMapList = json.decode(jsonData); + + var filename = p.basename(file.path).toLowerCase(); + + // 根据不同文件名,构建不同的数据 + if (filename == "all_bill_item.json") { + await _dbHelper.insertBillItemList( + jsonMapList.map((e) => BillItem.fromMap(e)).toList(), + ); + } else if (filename == "all_chat_history.json") { + await _dbHelper.insertChatList( + jsonMapList.map((e) => ChatSession.fromMap(e)).toList(), + ); + } else if (filename == "all_text2image_history.json") { + await _dbHelper.insertTextToImageResultList( + jsonMapList.map((e) => TextToImageResult.fromMap(e)).toList(), + ); + } else if (filename == "all_dish.json") { + await _dbHelper.insertDishList( + jsonMapList.map((e) => Dish.fromMap(e)).toList(), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("备份恢复"), + actions: [ + IconButton( + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text( + "备份恢复说明", + style: TextStyle(fontSize: 18.sp), + ), + content: Text(note), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("确定"), + ), + ], + ); + }, + ); + }, + icon: const Icon(Icons.help), + ), + ], + ), + body: isLoading ? buildLoader(isLoading) : buildBackupButton(), + ); + } + + buildBackupButton() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Text(tempPermMsg), + TextButton.icon( + onPressed: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text("全量备份"), + content: const Text("确认导出所有数据?"), + actions: [ + TextButton( + onPressed: () { + if (!mounted) return; + Navigator.pop(context, false); + }, + child: const Text("取消"), + ), + TextButton( + onPressed: () { + if (!mounted) return; + Navigator.pop(context, true); + }, + child: const Text("确定"), + ), + ], + ); + }, + ).then((value) { + if (value != null && value) exportAllData(); + }); + }, + icon: const Icon(Icons.backup), + label: Text( + "全量备份", + style: TextStyle(fontSize: 14.sp), + ), + ), + TextButton.icon( + onPressed: restoreDataFromBackup, + icon: const Icon(Icons.restore), + label: Text( + "覆写恢复", + style: TextStyle(fontSize: 14.sp), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/user_and_settings/index.dart b/lib/views/user_and_settings/index.dart new file mode 100644 index 0000000..6f1eaef --- /dev/null +++ b/lib/views/user_and_settings/index.dart @@ -0,0 +1,288 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:photo_view/photo_view.dart'; + +import '../../common/constants.dart'; +import '../../services/cus_get_storage.dart'; +import 'backup_and_restore/index.dart'; + +class UserAndSettings extends StatefulWidget { + const UserAndSettings({super.key}); + + @override + State createState() => _UserAndSettingsState(); +} + +class _UserAndSettingsState extends State { + // 用户头像路径 + String? _avatarPath = MyGetStorage().getUserAvatarPath(); + + // 修改头像 + // 选择图片来源 + Future _pickImage(ImageSource source) async { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: source); + if (pickedFile != null) { + await MyGetStorage().setUserAvatarPath(pickedFile.path); + setState(() { + _avatarPath = pickedFile.path; + }); + } + } + + @override + Widget build(BuildContext context) { + // 计算屏幕剩余的高度 + // 设备屏幕的总高度 + // - 屏幕顶部的安全区域高度,即状态栏的高度 + // - 屏幕底部的安全区域高度,即导航栏的高度或者虚拟按键的高度 + // - 应用程序顶部的工具栏(如 AppBar)的高度 + // - 应用程序底部的导航栏的高度 + // - 组件的边框间隔(不一定就是2) + double screenBodyHeight = MediaQuery.of(context).size.height - + MediaQuery.of(context).padding.top - + MediaQuery.of(context).padding.bottom - + kToolbarHeight - + kBottomNavigationBarHeight; + + print("screenBodyHeight--------$screenBodyHeight"); + + return Scaffold( + appBar: AppBar( + title: const Text("用户设置"), + actions: [ + TextButton( + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text( + "选择头像来源", + style: TextStyle(fontSize: 18.sp), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _pickImage(ImageSource.camera); + }, + child: const Text("拍照"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _pickImage(ImageSource.gallery); + }, + child: const Text("相册"), + ), + ], + ); + }, + ); + }, + child: const Text("更换头像"), + ), + ], + ), + body: ListView( + children: [ + /// 用户基本信息展示区域 + ..._buildBaseUserInfoArea(), + + SizedBox(height: 50.sp), + // 备份还原和更多设置 + SizedBox( + height: (screenBodyHeight - 250 - 20), + child: Center(child: _buildBakAndRestoreAndMoreSettingRow()), + ), + ], + ), + ); + } + + // 用户基本信息展示区域 + _buildBaseUserInfoArea() { + return [ + SizedBox(height: 10.sp), + Stack( + alignment: Alignment.center, + children: [ + // 没有修改头像,就用默认的 + if (_avatarPath == null) + CircleAvatar( + maxRadius: 60.sp, + backgroundColor: Colors.transparent, + backgroundImage: const AssetImage(placeholderImageUrl), + // y圆形头像的边框线 + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).primaryColor, + width: 2.sp, + ), + ), + ), + ), + if (_avatarPath != null) + GestureDetector( + onTap: () { + // 这个直接弹窗显示图片可以缩放 + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, // 设置背景透明 + child: PhotoView( + imageProvider: FileImage(File(_avatarPath!)), + // 设置图片背景为透明 + backgroundDecoration: const BoxDecoration( + color: Colors.transparent, + ), + // 可以旋转 + // enableRotation: true, + // 缩放的最大最小限制 + minScale: PhotoViewComputedScale.contained * 0.8, + maxScale: PhotoViewComputedScale.covered * 2, + errorBuilder: (context, url, error) => + const Icon(Icons.error), + ), + ); + }, + ); + }, + child: CircleAvatar( + maxRadius: 60.sp, + backgroundImage: FileImage(File(_avatarPath!)), + ), + ), + + // Positioned( + // top: 0.sp, + // right: 0.sp, + // child: TextButton( + // onPressed: () { + // showDialog( + // context: context, + // builder: (BuildContext context) { + // return AlertDialog( + // title: Text( + // "选择头像来源", + // style: TextStyle(fontSize: 18.sp), + // ), + // actions: [ + // TextButton( + // onPressed: () { + // Navigator.of(context).pop(); + // _pickImage(ImageSource.camera); + // }, + // child: const Text("拍照"), + // ), + // TextButton( + // onPressed: () { + // Navigator.of(context).pop(); + // _pickImage(ImageSource.gallery); + // }, + // child: const Text("相册"), + // ), + // ], + // ); + // }, + // ); + // }, + // child: const Text("更换头像"), + // ), + // ), + ], + ), + ]; + } + + _buildBakAndRestoreAndMoreSettingRow() { + return Column( + mainAxisSize: MainAxisSize.max, + children: [ + SizedBox( + height: 100.sp, + child: NewCusSettingCard( + leadingIcon: Icons.backup_outlined, + title: "备份恢复", + onTap: () { + // 处理相应的点击事件 + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const BackupAndRestore(), + ), + ); + }, + ), + ), + SizedBox( + height: 100.sp, + child: NewCusSettingCard( + leadingIcon: Icons.question_mark, + title: '常见问题(TBD)', + onTap: () { + showAboutDialog( + context: context, + applicationName: 'AI Light Life', + children: [ + const Text("author: SanotSu"), + const Text("phone: 13113155009"), + ], + ); + }, + ), + ), + ], + ); + } +} + +// 每个设置card抽出来复用 +class NewCusSettingCard extends StatelessWidget { + final IconData leadingIcon; + final String title; + final VoidCallback onTap; + + const NewCusSettingCard({ + Key? key, + required this.leadingIcon, + required this.title, + required this.onTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(2.sp), + child: Card( + elevation: 5, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.sp), + ), + child: Center( + child: ListTile( + leading: Icon(leadingIcon), + title: Text( + title, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ), + onTap: onTap, + ), + ), + ), + ); + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index ad05841..707f1a5 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -4,10 +4,10 @@ project(runner LANGUAGES CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. -set(BINARY_NAME "free_brief_accounting") +set(BINARY_NAME "ai_light_life") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.swm.free_brief_accounting") +set(APPLICATION_ID "com.swm.ai_light_life") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..7299b5c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,14 @@ #include "generated_plugin_registrant.h" +#include +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..786ff5c 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/linux/my_application.cc b/linux/my_application.cc index 5e61173..7410147 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) { if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "free_brief_accounting"); + gtk_header_bar_set_title(header_bar, "ai_light_life"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "free_brief_accounting"); + gtk_window_set_title(window, "ai_light_life"); } gtk_window_set_default_size(window, 1280, 720); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..92ca7aa 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,18 @@ import FlutterMacOS import Foundation +import connectivity_plus +import device_info_plus +import file_selector_macos +import path_provider_foundation +import sqflite +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index de3be64..071da32 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -64,7 +64,7 @@ 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* free_brief_accounting.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "free_brief_accounting.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* ai_light_life.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ai_light_life.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -131,7 +131,7 @@ 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* free_brief_accounting.app */, + 33CC10ED2044A3C60003C045 /* ai_light_life.app */, 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, ); name = Products; @@ -217,7 +217,7 @@ ); name = Runner; productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* free_brief_accounting.app */; + productReference = 33CC10ED2044A3C60003C045 /* ai_light_life.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -384,10 +384,10 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.swm.freeBriefAccounting.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.swm.aiLightLife.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/free_brief_accounting.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/free_brief_accounting"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ai_light_life.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ai_light_life"; }; name = Debug; }; @@ -398,10 +398,10 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.swm.freeBriefAccounting.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.swm.aiLightLife.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/free_brief_accounting.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/free_brief_accounting"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ai_light_life.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ai_light_life"; }; name = Release; }; @@ -412,10 +412,10 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.swm.freeBriefAccounting.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.swm.aiLightLife.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/free_brief_accounting.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/free_brief_accounting"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ai_light_life.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ai_light_life"; }; name = Profile; }; diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a279f45..0d8a05c 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -31,7 +31,7 @@ @@ -65,7 +65,7 @@ @@ -82,7 +82,7 @@ diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index 4ecee17..a8eb726 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -5,10 +5,10 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = free_brief_accounting +PRODUCT_NAME = ai_light_life // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.swm.freeBriefAccounting +PRODUCT_BUNDLE_IDENTIFIER = com.swm.aiLightLife // The copyright displayed in application information PRODUCT_COPYRIGHT = Copyright © 2024 com.swm. All rights reserved. diff --git a/pubspec.lock b/pubspec.lock index f8f646e..72cfc27 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: "direct main" + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" + args: + dependency: transitive + description: + name: args + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + url: "https://pub.dev" + source: hosted + version: "2.5.0" async: dependency: transitive description: @@ -17,6 +33,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + bottom_picker: + dependency: "direct main" + description: + name: bottom_picker + sha256: "7c690407c6c489bc7c556858c3f1b804aac73c3a128559f328cc829ba12983ad" + url: "https://pub.dev" + source: hosted + version: "2.8.0" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + carousel_slider: + dependency: "direct main" + description: + name: carousel_slider + sha256: "9c695cc963bf1d04a47bd6021f68befce8970bcd61d24938e1fb0918cf5d9c42" + url: "https://pub.dev" + source: hosted + version: "4.2.1" characters: dependency: transitive description: @@ -34,13 +90,53 @@ packages: source: hosted version: "1.1.1" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted version: "1.18.0" + community_material_icon: + dependency: transitive + description: + name: community_material_icon + sha256: bb389689f6278158d7b9d9b0c9433e603933283104fea226594590f61503fd08 + url: "https://pub.dev" + source: hosted + version: "5.9.55" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: db7a4e143dc72cc3cb2044ef9b052a7ebfe729513e6a82943bc3526f784365b8 + url: "https://pub.dev" + source: hosted + version: "6.0.3" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: b6a56efe1e6675be240de39107281d4034b64ac23438026355b4234042a35adb + url: "https://pub.dev" + source: hosted + version: "2.0.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32" + url: "https://pub.dev" + source: hosted + version: "0.3.4+1" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" cupertino_icons: dependency: "direct main" description: @@ -49,6 +145,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: eead12d1a1ed83d8283ab4c2f3fca23ac4082f29f25f29dff0f758f57d06ec91 + url: "https://pub.dev" + source: hosted + version: "10.1.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64 + url: "https://pub.dev" + source: hosted + version: "7.0.0" + dio: + dependency: "direct main" + description: + name: dio + sha256: "11e40df547d418cc0c4900a9318b26304e665da6fa4755399a9ff9efd09034b5" + url: "https://pub.dev" + source: hosted + version: "5.4.3+1" fake_async: dependency: transitive description: @@ -57,11 +185,123 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + file: + dependency: transitive + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: be325344c1f3070354a1d84a231a1ba75ea85d413774ec4bdf444c023342e030 + url: "https://pub.dev" + source: hosted + version: "5.5.0" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492" + url: "https://pub.dev" + source: hosted + version: "0.9.2+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: f42eacb83b318e183b1ae24eead1373ab1334084404c8c16e0354f9a3e55d385 + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0 + url: "https://pub.dev" + source: hosted + version: "0.9.3+1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "395d6b7831f21f3b989ebedbb785545932adb9afe2622c1ffacf7f4b53a7e544" + url: "https://pub.dev" + source: hosted + version: "3.3.2" + flutter_date_pickers: + dependency: "direct main" + description: + name: flutter_date_pickers + sha256: "302b1200d8859ec0bfe51c4eaea5f4911d1dbfc3c3f7d256dcf32d99fec219ee" + url: "https://pub.dev" + source: hosted + version: "0.4.3" + flutter_easyloading: + dependency: "direct main" + description: + name: flutter_easyloading + sha256: ba21a3c883544e582f9cc455a4a0907556714e1e9cf0eababfcb600da191d17c + url: "https://pub.dev" + source: hosted + version: "3.0.5" + flutter_form_builder: + dependency: "direct main" + description: + name: flutter_form_builder + sha256: "447f8808f68070f7df968e8063aada3c9d2e90e789b5b70f3b44e4b315212656" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + flutter_fortune_wheel: + dependency: "direct main" + description: + name: flutter_fortune_wheel + sha256: "2d8515483762f6f03766f655d88e1fb20bca947b9c8722dfd91c51aca2468596" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + flutter_hooks: + dependency: transitive + description: + name: flutter_hooks + sha256: "6a126f703b89499818d73305e4ce1e3de33b4ae1c5512e3b8eab4b986f46774c" + url: "https://pub.dev" + source: hosted + version: "0.18.6" flutter_lints: dependency: "direct dev" description: @@ -70,11 +310,205 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "85cc6f7daeae537844c92e2d56e2aff61b00095f8f77913b529ea4be12fc45ea" + url: "https://pub.dev" + source: hosted + version: "0.7.2+1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: c6b0b4c05c458e1c01ad9bcc14041dd7b1f6783d487be4386f793f47a8a4d03e + url: "https://pub.dev" + source: hosted + version: "2.0.20" + flutter_screenutil: + dependency: "direct main" + description: + name: flutter_screenutil + sha256: "8239210dd68bee6b0577aa4a090890342d04a136ce1c81f98ee513fc0ce891de" + url: "https://pub.dev" + source: hosted + version: "5.9.3" + flutter_spinkit: + dependency: transitive + description: + name: flutter_spinkit + sha256: d2696eed13732831414595b98863260e33e8882fc069ee80ec35d4ac9ddb0472 + url: "https://pub.dev" + source: hosted + version: "5.2.1" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + form_builder_file_picker: + dependency: "direct main" + description: + name: form_builder_file_picker + sha256: "4d115d0c2d7754576866d7a4d4e364bd095bf368297c40a66209ac70bd2e3d0f" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + form_builder_validators: + dependency: "direct main" + description: + name: form_builder_validators + sha256: "475853a177bfc832ec12551f752fd0001278358a6d42d2364681ff15f48f67cf" + url: "https://pub.dev" + source: hosted + version: "10.0.1" + get: + dependency: transitive + description: + name: get + sha256: e4e7335ede17452b391ed3b2ede016545706c01a02292a6c97619705e7d2a85e + url: "https://pub.dev" + source: hosted + version: "4.6.6" + get_storage: + dependency: "direct main" + description: + name: get_storage + sha256: "39db1fffe779d0c22b3a744376e86febe4ade43bf65e06eab5af707dc84185a2" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + http: + dependency: transitive + description: + name: http + sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + image_gallery_saver: + dependency: "direct main" + description: + name: image_gallery_saver + sha256: "0aba74216a4d9b0561510cb968015d56b701ba1bd94aace26aacdd8ae5761816" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "4161e1f843d8480d2e9025ee22411778c3c9eb7e40076dcf2da23d8242b7b51c" + url: "https://pub.dev" + source: hosted + version: "0.8.12+3" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "5d6eb13048cd47b60dbf1a5495424dea226c5faf3950e20bf8120a58efb5b5f3" + url: "https://pub.dev" + source: hosted + version: "3.0.4" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "6703696ad49f5c3c8356d576d7ace84d1faf459afb07accbb0fae780753ff447" + url: "https://pub.dev" + source: hosted + version: "0.8.12" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + url: "https://pub.dev" + source: hosted + version: "10.0.4" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" lints: dependency: transitive description: @@ -83,38 +517,254 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + markdown: + dependency: transitive + description: + name: markdown + sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + url: "https://pub.dev" + source: hosted + version: "7.2.2" matcher: dependency: transitive description: name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb url: "https://pub.dev" source: hosted - version: "0.12.16" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.8.0" meta: dependency: transitive description: name: meta - sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.10.0" - path: + version: "1.12.0" + mime: + dependency: transitive + description: + name: mime + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + month_picker_dialog: + dependency: "direct main" + description: + name: month_picker_dialog + sha256: a286b6134c278093916f59a0d2a111d0015132436e8aaccdde39fc1bdfc990b8 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + multi_select_flutter: + dependency: "direct main" + description: + name: multi_select_flutter + sha256: "503857b415d390d29159df8a9d92d83c6aac17aaf1c307fb7bcfc77d097d20ed" + url: "https://pub.dev" + source: hosted + version: "4.1.3" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + nm: dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + path: + dependency: "direct main" description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d + url: "https://pub.dev" + source: hosted + version: "2.2.4" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + url: "https://pub.dev" + source: hosted + version: "2.4.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" + url: "https://pub.dev" + source: hosted + version: "11.3.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "8bb852cd759488893805c3161d0b2b5db55db52f773dbb014420b304055ba2c5" + url: "https://pub.dev" + source: hosted + version: "12.0.6" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: e9ad66020b89ff1b63908f247c2c6f931c6e62699b756ef8b3c4569350cd8662 + url: "https://pub.dev" + source: hosted + version: "9.4.4" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d" + url: "https://pub.dev" + source: hosted + version: "0.1.1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: "48d4fcf201a1dad93ee869ab0d4101d084f49136ec82a8a06ed9cfeacab9fd20" url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "4.2.1" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + photo_view: + dependency: "direct main" + description: + name: photo_view + sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" + url: "https://pub.dev" + source: hosted + version: "0.15.0" + platform: + dependency: transitive + description: + name: platform + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pretty_dio_logger: + dependency: "direct main" + description: + name: pretty_dio_logger + sha256: "00b80053063935cf9a6190da344c5373b9d0e92da4c944c878ff2fbef0ef6dc2" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + provider: + dependency: transitive + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" + quiver: + dependency: transitive + description: + name: quiver + sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47 + url: "https://pub.dev" + source: hosted + version: "3.2.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + url: "https://pub.dev" + source: hosted + version: "0.27.7" sky_engine: dependency: transitive description: flutter @@ -128,6 +778,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d + url: "https://pub.dev" + source: hosted + version: "2.3.3+1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "28d8c66baee4968519fb8bd6cdbedad982d6e53359091f0b74544a9f32ec72d5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" stack_trace: dependency: transitive description: @@ -152,6 +826,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + syncfusion_flutter_charts: + dependency: "direct main" + description: + name: syncfusion_flutter_charts + sha256: cae165bc489d3b77fb095ab974c75fce5ea19aaedf392bb1ba85be1f1c7adc3d + url: "https://pub.dev" + source: hosted + version: "26.1.39" + syncfusion_flutter_core: + dependency: transitive + description: + name: syncfusion_flutter_core + sha256: "963e414e06abe520c9d0e039738bc6cbcf8eabf8c5964eb2181a76da1274a942" + url: "https://pub.dev" + source: hosted + version: "26.1.39" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + url: "https://pub.dev" + source: hosted + version: "3.1.0+1" term_glyph: dependency: transitive description: @@ -164,10 +862,98 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" + toggle_switch: + dependency: "direct main" + description: + name: toggle_switch + sha256: dca04512d7c23ed320d6c5ede1211a404f177d54d353bf785b07d15546a86ce5 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: ceb2625f0c24ade6ef6778d1de0b2e44f2db71fded235eb52295247feba8c5cf + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8" + url: "https://pub.dev" + source: hosted + version: "4.4.0" vector_math: dependency: transitive description: @@ -176,13 +962,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + url: "https://pub.dev" + source: hosted + version: "14.2.1" web: dependency: transitive description: name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + url: "https://pub.dev" + source: hosted + version: "0.5.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" + url: "https://pub.dev" + source: hosted + version: "5.2.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "6.5.0" sdks: - dart: ">=3.2.4 <4.0.0" + dart: ">=3.4.0 <4.0.0" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index 289c776..7fb8b32 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,8 +1,8 @@ -name: free_brief_accounting -description: "A new Flutter project." +name: ai_light_life +description: "一个包含极简的记账、幸运转盘随机菜品,和简单的AI对话、文生图、图像理解等功能的flutter应用。" # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -16,10 +16,10 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 0.1.0-beta.1 environment: - sdk: '>=3.2.4 <4.0.0' + sdk: ">=3.2.4 <4.0.0" # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -31,10 +31,48 @@ dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 + sqflite: ^2.3.3+1 # sqlite数据库工具库 + path_provider: ^2.1.3 # 获取主机平台文件系统上的常用位置 + path: ^1.9.0 # 基于字符串的路径操作库 + flutter_easyloading: ^3.0.5 # loading/toast 小部件 + flutter_screenutil: ^5.9.3 # 适配屏幕和字体大小的插件 + intl: ^0.19.0 # 国际化/本地化处理库 + flutter_localizations: + sdk: flutter + # collection: ^1.19.0 #1.19和flutter_test有冲突 + collection: ^1.18.0 # 集合相关的适用工具库 + bottom_picker: ^2.8.0 # 简洁,但不支持仅年月 + month_picker_dialog: ^4.0.0 # 支持仅年月,但是是弹窗,和原始组件类似 + flutter_date_pickers: ^0.4.3 + syncfusion_flutter_charts: ^26.1.39 # 图表库 + flutter_form_builder: ^9.3.0 # 表单组件 + form_builder_validators: ^10.0.1 # 表单验证 + form_builder_file_picker: ^4.1.0 # 表单中选择文件 + uuid: ^4.4.0 # uuid + flutter_markdown: ^0.7.2+1 # 使用md格式显示大模型的响应 + dio: ^5.4.3+1 # http client # http client + connectivity_plus: ^6.0.3 # 用于发现可以使用的网络连接类型 + pretty_dio_logger: ^1.3.1 # Dio 拦截器,它以漂亮、易于阅读的格式记录网络调用。 + crypto: ^3.0.3 # Dart 的一组加密哈希函数。 + # file_picker: ^8.0.3 # 备份恢复是选择文件路径(v8版本和上面表单中选择文件的库有冲突) + file_picker: ^5.5.0 + permission_handler: ^11.3.1 # 获取设备各项权限 + archive: ^3.6.1 # 解压缩文件 + device_info_plus: ^10.1.0 # 获取设备信息 + # animated_text_kit: ^4.2.2 # 动画文本特效工具,上次更新2022-06-05 但用户多 + toggle_switch: ^2.3.0 # 第三方的切换按钮 + cached_network_image: ^3.3.1 # 缓存网络图片 + image_gallery_saver: ^2.0.3 # 保存图片到图库(安卓9及以下无效) + photo_view: ^0.15.0 # 图片预览 + url_launcher: ^6.3.0 # 打开url + carousel_slider: ^4.2.1 # 轮播滑块小部件 + multi_select_flutter: ^4.1.3 # 一个用于以多种方式创建多选小部件的包 + image_picker: ^1.1.2 # 从设备选图片或者拍照 + flutter_fortune_wheel: ^1.3.1 # 幸运大转盘 + get_storage: ^2.1.1 # 简单键值对本地存储 dev_dependencies: flutter_test: @@ -52,7 +90,6 @@ dev_dependencies: # The following section is specific to Flutter packages. 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. @@ -62,6 +99,11 @@ flutter: # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg + assets: + - assets/ + - assets/mock_data/ + - assets/text2image_styles/ + - assets/images/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware diff --git a/test/widget_test.dart b/test/widget_test.dart index caf1c8a..e30ecbe 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,15 +5,14 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. +import 'package:ai_light_life/main.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:free_brief_accounting/main.dart'; - void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + await tester.pumpWidget(const AILightLifeApp()); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget); diff --git a/web/index.html b/web/index.html index ee98c5c..78615b5 100644 --- a/web/index.html +++ b/web/index.html @@ -23,13 +23,13 @@ - + - free_brief_accounting + ai_light_life