486 lines
16 KiB
Dart
486 lines
16 KiB
Dart
|
|
import 'dart:io';
|
|||
|
|
|
|||
|
|
import 'package:flutter/material.dart';
|
|||
|
|
import 'package:qhd_prevention/customWidget/custom_alert_dialog.dart';
|
|||
|
|
import 'package:qhd_prevention/customWidget/custom_button.dart';
|
|||
|
|
import 'package:qhd_prevention/customWidget/dotted_border_box.dart';
|
|||
|
|
import 'package:qhd_prevention/customWidget/photo_picker_row.dart';
|
|||
|
|
import 'package:qhd_prevention/customWidget/single_image_viewer.dart';
|
|||
|
|
import 'package:qhd_prevention/http/ApiService.dart';
|
|||
|
|
import 'package:qhd_prevention/tools/tools.dart';
|
|||
|
|
|
|||
|
|
// 操作类型枚举
|
|||
|
|
enum OperationType {
|
|||
|
|
// 检查
|
|||
|
|
check,
|
|||
|
|
// 安全措施
|
|||
|
|
measure,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class MultiTextFieldWithTitle extends StatefulWidget {
|
|||
|
|
final String label;
|
|||
|
|
final List<dynamic> items; // 改为包含文本和图片的列表(外部结构任意字段都会保留)
|
|||
|
|
final bool isEditable;
|
|||
|
|
final bool isAddImage;
|
|||
|
|
final String hintText;
|
|||
|
|
final double fontSize;
|
|||
|
|
final bool isRequired;
|
|||
|
|
final bool isDeletFirst;
|
|||
|
|
final OperationType operationType;
|
|||
|
|
|
|||
|
|
final ValueChanged<List<Map<String, dynamic>>> onItemsChanged;
|
|||
|
|
|
|||
|
|
const MultiTextFieldWithTitle({
|
|||
|
|
super.key,
|
|||
|
|
required this.label,
|
|||
|
|
required this.isEditable,
|
|||
|
|
required this.hintText,
|
|||
|
|
required this.onItemsChanged,
|
|||
|
|
this.fontSize = 15,
|
|||
|
|
this.items = const [],
|
|||
|
|
this.isRequired = true,
|
|||
|
|
this.isAddImage = true,
|
|||
|
|
this.isDeletFirst = false,
|
|||
|
|
this.operationType = OperationType.check,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
State<MultiTextFieldWithTitle> createState() =>
|
|||
|
|
_MultiTextFieldWithTitleState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class _MultiTextFieldWithTitleState extends State<MultiTextFieldWithTitle> {
|
|||
|
|
final List<TextEditingController> _controllers = [];
|
|||
|
|
final List<FocusNode> _focusNodes = [];
|
|||
|
|
|
|||
|
|
/// 保存每一项的完整 map(来自 widget.items 的深复制),这样可以保留额外字段
|
|||
|
|
final List<Map<String, dynamic>> _itemsData = [];
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void initState() {
|
|||
|
|
super.initState();
|
|||
|
|
_initializeFromItems();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _initializeFromItems() {
|
|||
|
|
// 释放旧资源
|
|||
|
|
for (var c in _controllers) {
|
|||
|
|
c.dispose();
|
|||
|
|
}
|
|||
|
|
for (var n in _focusNodes) {
|
|||
|
|
n.dispose();
|
|||
|
|
}
|
|||
|
|
_controllers.clear();
|
|||
|
|
_focusNodes.clear();
|
|||
|
|
_itemsData.clear();
|
|||
|
|
|
|||
|
|
if (widget.items.isNotEmpty) {
|
|||
|
|
for (final rawItem in widget.items) {
|
|||
|
|
// 保守地把外部 item 转成 Map 并深复制一份(避免引用同一对象)
|
|||
|
|
Map<String, dynamic> item;
|
|||
|
|
if (rawItem is Map<String, dynamic>) {
|
|||
|
|
item = Map<String, dynamic>.from(rawItem);
|
|||
|
|
} else {
|
|||
|
|
// 如果传入不是 map,尝试包成 map
|
|||
|
|
item = {'content': rawItem?.toString() ?? ''};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 兼容字段:优先 content,再兜底空字符串
|
|||
|
|
final text = item['content'] ?? '';
|
|||
|
|
|
|||
|
|
// 规范化:如果只有 imgPath(字符串)存在,也在 imgPaths 中保持 list 表示
|
|||
|
|
if (item.containsKey('imgPath') && !item.containsKey('imgPaths')) {
|
|||
|
|
final v = item['imgPath'];
|
|||
|
|
if (v == null) {
|
|||
|
|
item['imgPaths'] = <String>[];
|
|||
|
|
} else if (v is String && v.isNotEmpty) {
|
|||
|
|
item['imgPaths'] = [v];
|
|||
|
|
} else if (v is List) {
|
|||
|
|
item['imgPaths'] = List<String>.from(v.map((e) => e.toString()));
|
|||
|
|
} else {
|
|||
|
|
item['imgPaths'] = <String>[];
|
|||
|
|
}
|
|||
|
|
} else if (!item.containsKey('imgPaths')) {
|
|||
|
|
item['imgPaths'] = <String>[];
|
|||
|
|
} else {
|
|||
|
|
// 确保 imgPaths 是 List<String>
|
|||
|
|
final v = item['imgPaths'];
|
|||
|
|
if (v == null) {
|
|||
|
|
item['imgPaths'] = <String>[];
|
|||
|
|
} else if (v is List) {
|
|||
|
|
item['imgPaths'] = List<String>.from(v.map((e) => e.toString()));
|
|||
|
|
} else if (v is String && v.isNotEmpty) {
|
|||
|
|
item['imgPaths'] = [v];
|
|||
|
|
} else {
|
|||
|
|
item['imgPaths'] = <String>[];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final controller = TextEditingController(text: text);
|
|||
|
|
final node = FocusNode();
|
|||
|
|
controller.addListener(_onDataChanged);
|
|||
|
|
|
|||
|
|
_controllers.add(controller);
|
|||
|
|
_focusNodes.add(node);
|
|||
|
|
_itemsData.add(item);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// 没有初始值,创建一个空项
|
|||
|
|
if (widget.operationType == OperationType.check)
|
|||
|
|
_addNewItem(initialize: true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 触发一次回调(保证外面拿到初始结构)
|
|||
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|||
|
|
if (mounted) _onDataChanged();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void didUpdateWidget(covariant MultiTextFieldWithTitle oldWidget) {
|
|||
|
|
super.didUpdateWidget(oldWidget);
|
|||
|
|
|
|||
|
|
// 当外部传入的 items 发生变化时,重新初始化
|
|||
|
|
if (oldWidget.items != widget.items) {
|
|||
|
|
_initializeFromItems();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void dispose() {
|
|||
|
|
for (var c in _controllers) {
|
|||
|
|
c.dispose();
|
|||
|
|
}
|
|||
|
|
for (var n in _focusNodes) {
|
|||
|
|
n.dispose();
|
|||
|
|
}
|
|||
|
|
super.dispose();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _onDataChanged() {
|
|||
|
|
widget.onItemsChanged(_getAllItems());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取当前每一项的图片路径列表(List<String>)
|
|||
|
|
List<String> _getImageListForIndex(int index) {
|
|||
|
|
if (index < 0 || index >= _itemsData.length) return <String>[];
|
|||
|
|
final v = _itemsData[index]['imgPaths'];
|
|||
|
|
if (v == null) return <String>[];
|
|||
|
|
if (v is List<String>) return List<String>.from(v);
|
|||
|
|
if (v is List) return List<String>.from(v.map((e) => e.toString()));
|
|||
|
|
if (v is String && v.isNotEmpty) return [v];
|
|||
|
|
return <String>[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 将新的图片列表写回 itemsData,同时也同步 imgPath 单字符串字段以兼容外部期待
|
|||
|
|
void _setImageListForIndex(int index, List<String> list) {
|
|||
|
|
if (index < 0 || index >= _itemsData.length) return;
|
|||
|
|
_itemsData[index]['imgPaths'] = List<String>.from(list);
|
|||
|
|
// 同时更新单值字段,保持兼容(取第一张或空字符串)
|
|||
|
|
_itemsData[index]['imgPath'] = list.isNotEmpty ? list.first : '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 添加新条目
|
|||
|
|
void _addNewItem({bool initialize = false}) {
|
|||
|
|
// initialize 标识初始化阶段(避免重复触发回调两次,但我们仍然会触发 onDataChanged)
|
|||
|
|
setState(() {
|
|||
|
|
final newController = TextEditingController();
|
|||
|
|
final newFocusNode = FocusNode();
|
|||
|
|
|
|||
|
|
newController.addListener(_onDataChanged);
|
|||
|
|
|
|||
|
|
// 新建项:尽量包含 content、imgPath、imgPaths,其他字段为空(外部已有项的字段会被保留)
|
|||
|
|
final Map<String, dynamic> newItem = {
|
|||
|
|
'content': '',
|
|||
|
|
'imgPath': '',
|
|||
|
|
'imgPaths': <String>[],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
_controllers.add(newController);
|
|||
|
|
_focusNodes.add(newFocusNode);
|
|||
|
|
_itemsData.add(newItem);
|
|||
|
|
|
|||
|
|
// 只有在非初始化时触发回调;但仍然需要回调让调用方获取最新结构(加上 initialize 选项)
|
|||
|
|
if (!initialize) _onDataChanged();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 删除条目
|
|||
|
|
void _removeItem(int index) async {
|
|||
|
|
if (_controllers.length <= 1) return;
|
|||
|
|
|
|||
|
|
final confirmed = await CustomAlertDialog.showConfirm(
|
|||
|
|
context,
|
|||
|
|
title: '提示',
|
|||
|
|
content: '确定删除此项吗?',
|
|||
|
|
cancelText: '取消',
|
|||
|
|
confirmText: '确定',
|
|||
|
|
);
|
|||
|
|
if (!confirmed) return;
|
|||
|
|
|
|||
|
|
setState(() {
|
|||
|
|
_controllers[index].dispose();
|
|||
|
|
_focusNodes[index].dispose();
|
|||
|
|
|
|||
|
|
_controllers.removeAt(index);
|
|||
|
|
_focusNodes.removeAt(index);
|
|||
|
|
_itemsData.removeAt(index);
|
|||
|
|
|
|||
|
|
_onDataChanged();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 返回当前所有项的完整 Map 列表(保留原有字段,只更新 content/imgPaths/imgPath)
|
|||
|
|
List<Map<String, dynamic>> _getAllItems() {
|
|||
|
|
final List<Map<String, dynamic>> items = [];
|
|||
|
|
for (int i = 0; i < _itemsData.length; i++) {
|
|||
|
|
final Map<String, dynamic> copy = Map<String, dynamic>.from(
|
|||
|
|
_itemsData[i],
|
|||
|
|
);
|
|||
|
|
// 确保 content 与图片字段同步最新值
|
|||
|
|
copy['content'] = _controllers[i].text;
|
|||
|
|
// 保证 imgPaths 是 List<String>
|
|||
|
|
final imgs = _getImageListForIndex(i);
|
|||
|
|
copy['imgPaths'] = imgs;
|
|||
|
|
copy['imgPath'] = imgs.isNotEmpty ? imgs.first : '';
|
|||
|
|
items.add(copy);
|
|||
|
|
}
|
|||
|
|
return items;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
return Container(
|
|||
|
|
padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 12),
|
|||
|
|
child: Column(
|
|||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|||
|
|
children: [
|
|||
|
|
// 标题行
|
|||
|
|
InkWell(
|
|||
|
|
child: Row(
|
|||
|
|
children: [
|
|||
|
|
Flexible(
|
|||
|
|
fit: FlexFit.loose,
|
|||
|
|
child: Row(
|
|||
|
|
children: [
|
|||
|
|
if (widget.isRequired && widget.isEditable)
|
|||
|
|
const Text('* ', style: TextStyle(color: Colors.red)),
|
|||
|
|
Flexible(
|
|||
|
|
child: Text(
|
|||
|
|
widget.label,
|
|||
|
|
style: TextStyle(
|
|||
|
|
fontSize: widget.fontSize,
|
|||
|
|
fontWeight: FontWeight.bold,
|
|||
|
|
),
|
|||
|
|
maxLines: 1,
|
|||
|
|
overflow: TextOverflow.ellipsis,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
const SizedBox(width: 8),
|
|||
|
|
if (widget.isEditable)
|
|||
|
|
CustomButton(
|
|||
|
|
text: " 添加 ",
|
|||
|
|
height: 30,
|
|||
|
|
padding: const EdgeInsets.symmetric(
|
|||
|
|
vertical: 2,
|
|||
|
|
horizontal: 5,
|
|||
|
|
),
|
|||
|
|
backgroundColor: Colors.blue,
|
|||
|
|
onPressed: _addNewItem,
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 8),
|
|||
|
|
|
|||
|
|
// 条目区域
|
|||
|
|
Column(
|
|||
|
|
children: [
|
|||
|
|
..._controllers.asMap().entries.map((entry) {
|
|||
|
|
final index = entry.key;
|
|||
|
|
final itemMap = _itemsData[index];
|
|||
|
|
final item = {
|
|||
|
|
'content': _controllers[index].text,
|
|||
|
|
'imgPaths': _getImageListForIndex(index),
|
|||
|
|
// 这里只是便于 _buildItem 使用;真实的完整 map 存在于 _itemsData
|
|||
|
|
};
|
|||
|
|
return _buildItem(index, itemMap);
|
|||
|
|
}).toList(),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Widget _buildItem(int index, Map<String, dynamic> fullItemMap) {
|
|||
|
|
final text = _controllers[index].text;
|
|||
|
|
final imageList = _getImageListForIndex(index);
|
|||
|
|
final itemTitle =
|
|||
|
|
widget.operationType == OperationType.check
|
|||
|
|
? '检查情况${index + 1}'
|
|||
|
|
: '其他安全措施${index + 1}';
|
|||
|
|
return Container(
|
|||
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|||
|
|
child: Column(
|
|||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|||
|
|
children: [
|
|||
|
|
// 标题行(显示"检查情况1、2、3...")
|
|||
|
|
if (_controllers.length > 1)
|
|||
|
|
Padding(
|
|||
|
|
padding: const EdgeInsets.only(top: 0, left: 8, right: 8),
|
|||
|
|
child: Row(
|
|||
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|||
|
|
children: [
|
|||
|
|
if (index > 0)
|
|||
|
|
Text(
|
|||
|
|
itemTitle,
|
|||
|
|
style: TextStyle(
|
|||
|
|
fontSize: widget.fontSize,
|
|||
|
|
fontWeight: FontWeight.bold,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
if (widget.isEditable && index > 0)
|
|||
|
|
IconButton(
|
|||
|
|
onPressed: () => _removeItem(index),
|
|||
|
|
icon: const Icon(
|
|||
|
|
Icons.close,
|
|||
|
|
color: Colors.red,
|
|||
|
|
size: 30,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
|
|||
|
|
// 内容区域
|
|||
|
|
Padding(
|
|||
|
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 7),
|
|||
|
|
child: SizedBox(
|
|||
|
|
width: double.maxFinite,
|
|||
|
|
child: DottedBorderBox(
|
|||
|
|
child:
|
|||
|
|
widget.isEditable
|
|||
|
|
? TextField(
|
|||
|
|
controller: _controllers[index],
|
|||
|
|
decoration: InputDecoration(
|
|||
|
|
hintText: widget.hintText,
|
|||
|
|
),
|
|||
|
|
focusNode: _focusNodes[index],
|
|||
|
|
keyboardType: TextInputType.multiline,
|
|||
|
|
maxLines: 3,
|
|||
|
|
style: TextStyle(fontSize: widget.fontSize),
|
|||
|
|
)
|
|||
|
|
: Padding(
|
|||
|
|
padding: const EdgeInsets.all(12),
|
|||
|
|
child: Text(
|
|||
|
|
text.isEmpty ? '暂无内容' : text,
|
|||
|
|
style: TextStyle(
|
|||
|
|
fontSize: widget.fontSize,
|
|||
|
|
color: text.isEmpty ? Colors.grey : Colors.black,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
|
|||
|
|
// 图片区域(编辑态)
|
|||
|
|
if (widget.isEditable && widget.isAddImage)
|
|||
|
|
RepairedPhotoSection(
|
|||
|
|
title: '图片',
|
|||
|
|
maxCount: 1,
|
|||
|
|
isEdit: widget.isEditable,
|
|||
|
|
followInitialUpdates: true,
|
|||
|
|
// 关键:只传当前条目的图片数组
|
|||
|
|
initialMediaPaths: imageList,
|
|||
|
|
// 当本地文件变化(用户选择/删除)回调,用它同步到 _itemsData
|
|||
|
|
onChanged: (files) {
|
|||
|
|
// setState(() {
|
|||
|
|
// if (files.isNotEmpty) {
|
|||
|
|
// imageList[index] = files.first.path;
|
|||
|
|
// } else {
|
|||
|
|
// imageList[index] = '';
|
|||
|
|
// }
|
|||
|
|
// });
|
|||
|
|
// _onDataChanged();
|
|||
|
|
},
|
|||
|
|
onMediaAdded: (localPath) async {
|
|||
|
|
// 也可能单独使用该回调,本处把它当成新增单张图片
|
|||
|
|
final List<String> current = _getImageListForIndex(index);
|
|||
|
|
current.add(localPath);
|
|||
|
|
setState(() {
|
|||
|
|
_setImageListForIndex(index, current);
|
|||
|
|
});
|
|||
|
|
_onDataChanged();
|
|||
|
|
},
|
|||
|
|
onMediaRemoved: (localPath) async {
|
|||
|
|
final List<String> current = _getImageListForIndex(index);
|
|||
|
|
current.removeWhere((p) => p == localPath);
|
|||
|
|
setState(() {
|
|||
|
|
_setImageListForIndex(index, current);
|
|||
|
|
});
|
|||
|
|
_onDataChanged();
|
|||
|
|
},
|
|||
|
|
onMediaTapped: (path) async {
|
|||
|
|
presentOpaque(SingleImageViewer(imageUrl: path), context);
|
|||
|
|
},
|
|||
|
|
onAiIdentify: () {},
|
|||
|
|
),
|
|||
|
|
|
|||
|
|
// 非编辑态显示只读图片
|
|||
|
|
if (!widget.isEditable && imageList.isNotEmpty)
|
|||
|
|
_buildReadOnlyImage(imageList.first),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Widget _buildReadOnlyImage(String imagePath) {
|
|||
|
|
return Padding(
|
|||
|
|
padding: const EdgeInsets.symmetric(horizontal: 7),
|
|||
|
|
child: Column(
|
|||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|||
|
|
children: [
|
|||
|
|
const SizedBox(height: 8),
|
|||
|
|
Text(
|
|||
|
|
'图片:',
|
|||
|
|
style: TextStyle(
|
|||
|
|
fontSize: widget.fontSize - 1,
|
|||
|
|
fontWeight: FontWeight.bold,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 4),
|
|||
|
|
GestureDetector(
|
|||
|
|
child: Container(
|
|||
|
|
width: 100,
|
|||
|
|
height: 100,
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
borderRadius: BorderRadius.circular(8),
|
|||
|
|
image: DecorationImage(
|
|||
|
|
image: _getImageProvider(imagePath),
|
|||
|
|
fit: BoxFit.cover,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
onTap: () {
|
|||
|
|
presentOpaque(SingleImageViewer(imageUrl: imagePath), context);
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ImageProvider _getImageProvider(String imagePath) {
|
|||
|
|
if (imagePath.startsWith('http')) {
|
|||
|
|
return NetworkImage(imagePath);
|
|||
|
|
} else {
|
|||
|
|
return FileImage(File(imagePath));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|