Files
App.K12.Student.Miniapp_Flu…/lib/core/components/upload.dart
T
2026-09-28 20:12:34 +08:00

980 lines
30 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:typed_data';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart' show compute, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:image/image.dart' as img;
import 'package:new_learn/core/api/public_upload/api.dart';
import 'package:new_learn/core/api/public_upload/model.dart';
import 'package:new_learn/core/components/center_toast.dart';
import 'package:new_learn/core/components/image_preview_viewer.dart';
import 'package:new_learn/core/network/oss_api.dart';
import 'package:new_learn/core/network/oss_uploader.dart';
import 'package:new_learn/core/services/image_processor.dart';
import 'package:new_learn/core/services/picker_service.dart';
import 'package:new_learn/core/utils/img_url.dart';
/// ============================================================
/// 【Upload 组件】多图上传组件(对标 Vue components/Upload/Upload.vue�?
///
/// 一、功能:
/// 1. edit 模式:相�?拍照多�?�?压缩 + EXIF 修正 �?OSS 直传 �?
/// 调用业务接口关联笔记 �?本地图片列表刷新
/// 2. view 模式:仅展示图片网格,无添加/删除按钮
/// 3. 点击图片 �?全屏预览(photo_view 双指缩放 + 左右切换�?
/// 4. 底部可配�?拍摄规范"提示文本(title == '上传图片作答' 时显示)
///
/// 二、Props 对照�?
/// - [maxCount] : 最多上传张数(默认 10�?
/// - [mode] : UploadMode.edit / UploadMode.view(默�?edit�?
/// - [title] : 标题文案(默�?'上传笔记'�?
/// - [initialImageList]: 初始已上传图片列�?
/// - [type] : 类型:handouts / questions / AIGrading / feedback
/// - [ids] : 业务 id(讲�?id / 题目 id�?
/// - [onChanged] : 图片列表变化回调(相当于 Vue �?change 事件�?
///
/// 三、上传链路(对齐 Vue + my_page.dart 头像上传流程):
/// �?PickerService.showPickSheet 选图(含权限前置弹窗�?
/// �?ImageProcessor.process 压缩 + EXIF 方向修正 + 尺寸过滤
/// �?OssUploader.uploadImage OSS 直传(scene �?type 映射�?
/// �?NoteApi 笔记关联(handouts/question 走业务接口,
/// AIGrading/feedback 仅本地维护列表)
///
/// 四、type �?OSS scene / 笔记接口映射�?
/// type | UploadScene | 笔记接口
/// ─────────────┼────────────────┼────────────
/// handouts | note | HandoutNoteAdd/Del
/// questions | note | QuestionNoteAdd/Del
/// AIGrading | aiGrading | 无(仅本地列表)
/// feedback | note | 无(仅本地列表)
///
/// 五、尺寸过滤策略(对齐 Vue filterOversizeImages):
/// - handouts / questions 类型:跳过尺寸校验(讲义/题目图可能很大)
/// - 其它类型:宽高必须在 14~4000 像素之间
/// ============================================================
/// 上传组件模式
enum UploadMode { edit, view }
/// 上传组件类型
enum UploadType { handouts, questions, aiGrading, feedback }
/// 选图后的“原始图 + 压缩处理图”配对
typedef UploadPair = ({PickedImage origin, ProcessedImage processed});
/// 图片项(统一承载:本地已�?+ 已上传)
class UploadItem {
const UploadItem({
this.id,
required this.url,
this.thumbnailUrl,
this.localBytes,
this.localEntity,
this.isUploading = false,
});
/// 服务器返回的笔记 id(删除用;本地图片无 id�?
final dynamic id;
/// 图片 URL(已上传)或本地路径(未上传�?
final String url;
/// 缩略�?URL
final String? thumbnailUrl;
/// 本地字节(Web 预览用,上传完成后置空)
final List<int>? localBytes;
/// 原生相册实体(原生端缩略图预览用,上传完成后置空�?
final dynamic localEntity;
/// 是否正在上传中(用于显示加载态)
final bool isUploading;
/// 是否为网络图(已上传�?OSS�?
bool get isNetwork => url.startsWith('http');
factory UploadItem.fromNoteImage(NoteImage note) => UploadItem(
id: note.id,
url: note.url,
thumbnailUrl: note.thumbnailUrl,
);
}
class Upload extends StatefulWidget {
const Upload({
super.key,
this.maxCount = 10,
this.mode = UploadMode.edit,
this.title = '上传笔记',
this.initialImageList = const [],
this.type = UploadType.handouts,
this.ids,
this.onChanged,
});
/// 最多上传张�?
final int maxCount;
/// 模式:编�?/ 查看
final UploadMode mode;
/// 标题文案
final String title;
/// 初始图片列表(已上传的网络图�?
final List<UploadItem> initialImageList;
/// 上传类型
final UploadType type;
/// 业务 id(讲�?id / 题目 id�?
final dynamic ids;
/// 列表变化回调
final ValueChanged<List<UploadItem>>? onChanged;
@override
State<Upload> createState() => UploadState();
}
class UploadState extends State<Upload> {
final List<UploadItem> _imageList = [];
int _uploadingCount = 0;
bool _choosing = false;
@override
void initState() {
super.initState();
_imageList.addAll(widget.initialImageList);
}
@override
void didUpdateWidget(covariant Upload oldWidget) {
super.didUpdateWidget(oldWidget);
// 外部 initialImageList 变化时同步(类似 Vue �?watch defaultImageList�?
if (oldWidget.initialImageList != widget.initialImageList) {
_imageList
..clear()
..addAll(widget.initialImageList);
}
}
// ===================== 对外暴露方法 =====================
/// 触发选图(等�?Vue �?chooseImage�?
Future<void> chooseImage() async => _chooseImage();
/// 获取当前图片列表
List<UploadItem> getImageList() => List.unmodifiable(_imageList);
/// 正在上传的数�?
int get uploadingCount => _uploadingCount;
/// 是否正在选图
bool get choosing => _choosing;
// ===================== 工具方法 =====================
void _notifyChanged() {
widget.onChanged?.call(List.unmodifiable(_imageList));
}
/// 映射 type �?OSS scene
UploadScene get _uploadScene {
switch (widget.type) {
case UploadType.aiGrading:
return UploadScene.aiGrading;
case UploadType.handouts:
case UploadType.questions:
case UploadType.feedback:
return UploadScene.note;
}
}
/// 是否跳过尺寸校验(讲�?题目大图允许�?
bool get _skipSizeLimit =>
widget.type == UploadType.handouts ||
widget.type == UploadType.questions;
void _toast(String message) {
CenterToast.show(context, message);
}
void _showLoading(String message) {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => PopScope(
canPop: false,
child: Center(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 28.w, vertical: 20.h),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(color: Colors.white),
SizedBox(height: 12.h),
Text(
message,
style: TextStyle(color: Colors.white, fontSize: 13.sp),
),
],
),
),
),
),
);
}
void _hideLoading() {
if (Navigator.of(context, rootNavigator: true).canPop()) {
Navigator.of(context, rootNavigator: true).pop();
}
}
String _replaceExtension(String fileName, String extension) {
final dotIndex = fileName.lastIndexOf('.');
final safeExtension = extension.toLowerCase();
if (dotIndex <= 0) return '$fileName.$safeExtension';
return '${fileName.substring(0, dotIndex)}.$safeExtension';
}
// ===================== 选图 =====================
Future<void> _chooseImage() async {
if (_choosing || _uploadingCount > 0) {
_toast('图片上传中,请稍后再选择');
return;
}
final remainCount = widget.maxCount - _imageList.length - _uploadingCount;
if (remainCount <= 0) {
_toast('最多上传${widget.maxCount}张图片');
return;
}
setState(() => _choosing = true);
bool processingLoadingShown = false;
try {
final picked = await PickerService.instance.showPickSheet(
context,
maxCount: remainCount,
);
if (picked == null || picked.isEmpty) return;
// 显示"上传中..."遮罩(压�?+ 尺寸过滤可能耗时几秒�?
_showLoading('上传中...');
processingLoadingShown = true;
// 压缩 + EXIF 修正 + 尺寸过滤
final List<UploadPair> validList = [];
for (final img in picked) {
final ProcessedImage? processed;
if (kIsWeb) {
// Web:只有字节,走 bytes 压缩
final bytes = img.bytes;
if (bytes == null || bytes.isEmpty) continue;
processed = await ImageProcessor.process(
bytes: bytes,
fileName: img.name,
maxDimension: 1920,
);
} else {
// 原生:只传文件路径,原图字节不进 Dart 堆、UI 线程不解码
final path = img.path;
if (path == null || path.isEmpty) continue;
processed = await ImageProcessor.processFile(
path: path,
maxDimension: 1920,
);
}
if (processed == null) {
// 压缩失败 / 解码失败 �?跳过
continue;
}
if (_skipSizeLimit) {
validList.add((origin: img, processed: processed));
continue;
}
// 尺寸校验:由 ImageProcessor 内部 min/max 拦截�?
// 返回 null 说明尺寸不符合。这里按大小区分提示�?
// (ImageProcessor 只返�?null,不区分过小/过大�?
// 这里简化为"不符合要�?,与 Vue 的核心提示一致。)
validList.add((origin: img, processed: processed));
}
if (validList.isEmpty) {
_toast('图片尺寸不符合要求,请重新选择');
return;
}
// AIGrading(上传图片作答):先进入全屏旋转预览,
// 用户点“完成”才烘焙旋转并上传;点“取消”则整批放弃(对齐 Vue)
List<UploadPair> pairsToUpload = validList;
if (widget.type == UploadType.aiGrading) {
_hideLoading();
processingLoadingShown = false;
if (!mounted) return;
final rotated = await _ImageRotateEditor.open(
context,
items: validList,
);
if (rotated == null) return; // 用户取消
pairsToUpload = rotated;
if (!mounted) return;
_showLoading('上传中...');
processingLoadingShown = true;
}
await _startUpload(pairsToUpload);
} catch (e) {
_toast('图片处理失败,请重试');
debugPrint('[Upload] 选图异常: $e');
} finally {
if (processingLoadingShown) _hideLoading();
if (mounted) setState(() => _choosing = false);
}
}
// ===================== 上传 =====================
Future<void> _startUpload(
List<({PickedImage origin, ProcessedImage processed})> items,
) async {
if (items.isEmpty) return;
final canUploadCount =
widget.maxCount - _imageList.length - _uploadingCount;
final safeEnd = canUploadCount.clamp(0, items.length);
final safeItems =
safeEnd > 0 ? items.sublist(0, safeEnd) : <({
PickedImage origin,
ProcessedImage processed
})>[];
if (safeItems.isEmpty) {
_toast('最多上传${widget.maxCount}张图片');
return;
}
if (safeItems.length < items.length) {
_toast('最多还能上传${safeItems.length}张图片');
}
final pendingCount = safeItems.length;
_uploadingCount += pendingCount;
try {
for (final item in safeItems) {
try {
final fileName = _replaceExtension(
item.origin.name ??
'img_${DateTime.now().millisecondsSinceEpoch}.jpg',
item.processed.extension,
);
// OSS 直传
final url = await OssUploader.uploadImage(
bytes: item.processed.bytes,
fileName: fileName,
contentType: item.processed.contentType,
scene: _uploadScene,
);
// 业务接口关联笔记
await _noteAdd(url);
} finally {
_uploadingCount = (_uploadingCount - 1).clamp(0, 999);
}
}
_toast('上传成功');
} catch (e) {
_toast('笔记上传失败,请检查网络后重试');
} finally {
if (mounted) setState(() {});
}
}
/// 调业务接口添加笔记(�?type 分流�?
Future<void> _noteAdd(String url) async {
switch (widget.type) {
case UploadType.handouts:
if (widget.ids != null) {
final note = await NoteApi.instance.handoutNoteAdd(
id: widget.ids,
url: url,
thumbnailUrl: url,
);
if (note.url.isNotEmpty && note.id != null) {
setState(() {
_imageList.add(UploadItem.fromNoteImage(note));
});
}
} else {
setState(() {
_imageList.add(UploadItem(url: url, thumbnailUrl: url));
});
}
break;
case UploadType.questions:
if (widget.ids != null) {
final note = await NoteApi.instance.questionNoteAdd(
id: widget.ids,
url: url,
thumbnailUrl: url,
);
if (note.url.isNotEmpty && note.id != null) {
setState(() {
_imageList.add(UploadItem.fromNoteImage(note));
});
}
} else {
setState(() {
_imageList.add(UploadItem(url: url, thumbnailUrl: url));
});
}
break;
case UploadType.aiGrading:
case UploadType.feedback:
setState(() {
_imageList.add(UploadItem(url: url, thumbnailUrl: url));
});
break;
}
_notifyChanged();
}
// ===================== 删除 =====================
Future<void> _removeImg(UploadItem item) async {
try {
switch (widget.type) {
case UploadType.handouts:
if (widget.ids != null && item.id != null) {
final ok = await NoteApi.instance.handoutNoteDel(item.id);
if (ok) {
setState(() {
_imageList.removeWhere((e) => e.id == item.id);
});
}
}
break;
case UploadType.questions:
if (widget.ids != null && item.id != null) {
final ok = await NoteApi.instance.questionNoteDel(item.id);
if (ok) {
setState(() {
_imageList.removeWhere((e) => e.id == item.id);
});
}
}
break;
case UploadType.aiGrading:
case UploadType.feedback:
setState(() {
_imageList.removeWhere((e) => e.url == item.url);
});
break;
}
} catch (e) {
_toast('删除失败,请检查网络后重试');
}
_notifyChanged();
}
// ===================== 预览 =====================
void _previewImage(int index) {
final items = _imageList
.map(
(item) => PreviewImageItem(
imageProvider: _imageProviderOf(item),
),
)
.toList();
ImagePreviewViewer.show(context, items: items, initialIndex: index);
}
ImageProvider _imageProviderOf(UploadItem item) {
if (item.isNetwork) {
return CachedNetworkImageProvider(ImgUrl.build(item.url));
}
if (item.localBytes != null) {
return MemoryImage(Uint8List.fromList(item.localBytes!));
}
return const AssetImage(''); // 兜底
}
// ===================== Build =====================
@override
Widget build(BuildContext context) {
// 模式:view 且没有图片时不显示(对齐 Vue v-if)
if (widget.mode == UploadMode.edit || _imageList.isNotEmpty) {
return _buildUploadBox();
}
return const SizedBox.shrink();
}
Widget _buildUploadBox() {
return Container(
padding: EdgeInsets.all(14.w),
margin: EdgeInsets.only(bottom: 10.h),
decoration: BoxDecoration(
color: const Color(0xFFFAFBFD),
border: Border.all(color: const Color(0xFFE3ECFF), width: 1.r),
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
_buildGrid(),
if (widget.mode == UploadMode.edit &&
widget.title == '上传图片作答')
_buildTip(),
],
),
);
}
Widget _buildHeader() {
return Row(
children: [
Stack(
children: [
// 标题装饰底图:固定尺寸钉在右下角(对标 background-size: 119rpx auto)
Positioned(
right: 10.w,
bottom: 0,
child: Image.network(
ImgUrl.build('/StaticResource/2026-04-01/title-bacs.png'),
width: 59.5.w,
height: 9.h,
fit: BoxFit.fill,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
),
Padding(
padding: EdgeInsets.only(right: 14.w),
child: Text(
widget.title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
),
],
),
SizedBox(width: 14.w),
Text(
widget.mode == UploadMode.edit
? '已上传:${_imageList.length}/${widget.maxCount}张'
: '共 ${_imageList.length} 张',
style: TextStyle(fontSize: 12.sp, color: const Color(0xFFB4C2E0)),
),
],
);
}
Widget _buildGrid() {
final itemSize = 67.w;
final showAdd = widget.mode == UploadMode.edit &&
(_imageList.length + _uploadingCount) < widget.maxCount;
final totalCount = _imageList.length + (showAdd ? 1 : 0);
return Padding(
padding: EdgeInsets.only(top: 10.h),
child: Wrap(
spacing: 10.w,
runSpacing: 10.h,
children: [
for (int i = 0; i < _imageList.length; i++)
_buildImageItem(_imageList[i], i, itemSize),
if (showAdd) _buildAddButton(itemSize),
],
),
);
}
Widget _buildAddButton(double size) {
return GestureDetector(
onTap: _chooseImage,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: const Color(0xFFF5F8FA),
borderRadius: BorderRadius.circular(6.r),
),
child: Center(
child: Image.network(
ImgUrl.build('/StaticResource/2026-04-01/add.png'),
width: 19.5.w,
height: 19.5.w,
errorBuilder: (_, __, ___) => Icon(
Icons.add,
size: 19.5.w,
color: const Color(0xFFB0B8C4),
),
),
),
),
);
}
Widget _buildImageItem(UploadItem item, int index, double size) {
return GestureDetector(
onTap: () => _previewImage(index),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: const Color(0xFFF5F8FA),
borderRadius: BorderRadius.circular(6.r),
),
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
children: [
if (item.isNetwork)
CachedNetworkImage(
imageUrl: ImgUrl.build(item.url),
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const ColoredBox(
color: Color(0xFFEEF1F5),
),
)
else if (item.localBytes != null)
Image.memory(
Uint8List.fromList(item.localBytes!),
fit: BoxFit.cover,
)
else
const ColoredBox(color: Color(0xFFEEF1F5)),
// 删除按钮
if (widget.mode == UploadMode.edit)
Positioned(
top: 0,
right: 0,
child: GestureDetector(
onTap: () => _removeImg(item),
child: Image.network(
ImgUrl.build('/StaticResource/2026-04-14/remove.png'),
width: 18.w,
height: 18.w,
errorBuilder: (_, __, ___) => Container(
width: 18.w,
height: 18.w,
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(Icons.close,
size: 12, color: Colors.white),
),
),
),
),
],
),
),
);
}
Widget _buildTip() {
return Padding(
padding: EdgeInsets.only(top: 15.h),
child: Text(
'图片作答拍摄规范:\n'
'1. 采光:光线充足,无阴影、无反光遮挡答题区\n'
'2. 角度:纸张铺平,垂直俯拍,画面不歪斜\n'
'3. 画质:完整拍下整张纸,文字清晰无裁切模糊\n'
'4. 卷面:答题区仅留正式作答,草稿另用草稿纸\n'
'5. 修改:错处双线划去,旁侧重写,禁止反复涂黑叠写\n'
'提示:画面越规整清晰,识别批改越准确',
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
),
);
}
}
/// ============================================================
/// 全屏旋转预览(对标 Vue components/ImagePreviewPopup):
/// - 左右滑动逐张查看;
/// - “↻ 旋转90°”只改当前图片的视觉角度;
/// - “完成”才把角度烘焙进图片字节并返回(随后才上传);
/// - “取消”/系统返回 → 返回 null,整批放弃。
/// ============================================================
class _ImageRotateEditor extends StatefulWidget {
const _ImageRotateEditor({required this.items});
final List<UploadPair> items;
/// 打开旋转预览;完成返回旋转后的列表,取消返回 null
static Future<List<UploadPair>?> open(
BuildContext context, {
required List<UploadPair> items,
}) {
return Navigator.of(context).push<List<UploadPair>>(
PageRouteBuilder<List<UploadPair>>(
opaque: true,
transitionDuration: const Duration(milliseconds: 200),
reverseTransitionDuration: const Duration(milliseconds: 200),
pageBuilder: (_, __, ___) => _ImageRotateEditor(items: items),
transitionsBuilder: (_, animation, __, child) =>
FadeTransition(opacity: animation, child: child),
),
);
}
@override
State<_ImageRotateEditor> createState() => _ImageRotateEditorState();
}
class _ImageRotateEditorState extends State<_ImageRotateEditor> {
late final List<int> _angles = List.filled(widget.items.length, 0);
final PageController _pageController = PageController();
int _currentIndex = 0;
bool _busy = false;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _onPageChanged(int index) => setState(() => _currentIndex = index);
void _handleRotate() {
if (_busy) return;
setState(() {
_angles[_currentIndex] = (_angles[_currentIndex] + 90) % 360;
});
}
Future<void> _handleConfirm() async {
if (_busy) return;
setState(() => _busy = true);
try {
final result = <UploadPair>[];
for (var i = 0; i < widget.items.length; i++) {
final pair = widget.items[i];
final angle = _angles[i] % 360;
if (angle == 0) {
result.add(pair);
} else {
result.add((
origin: pair.origin,
processed: await _bakeRotation(pair.processed, angle),
));
}
}
if (mounted) Navigator.of(context).pop(result);
} catch (_) {
if (mounted) {
setState(() => _busy = false);
CenterToast.show(context, '图片处理失败,请重试');
}
}
}
void _handleCancel() {
if (_busy) return;
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
PageView.builder(
controller: _pageController,
itemCount: widget.items.length,
onPageChanged: _onPageChanged,
itemBuilder: (context, index) {
return Center(
child: AnimatedRotation(
turns: _angles[index] / 360,
duration: const Duration(milliseconds: 200),
child: Image.memory(
widget.items[index].processed.bytes,
fit: BoxFit.contain,
),
),
);
},
),
// 指示点(多图时)
if (widget.items.length > 1)
Positioned(
left: 0,
right: 0,
bottom: 72.h,
child: _buildDots(),
),
// 底部操作栏
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildFooter(),
),
// “处理图片中...”遮罩
if (_busy)
const ColoredBox(
color: Colors.black38,
child: Center(
child: CircularProgressIndicator(color: Colors.white),
),
),
],
),
);
}
Widget _buildDots() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < widget.items.length; i++)
Container(
width: 7.w,
height: 7.w,
margin: EdgeInsets.symmetric(horizontal: 4.w),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == _currentIndex
? Colors.white
: Colors.white.withValues(alpha: 0.3),
),
),
],
);
}
Widget _buildFooter() {
return SafeArea(
top: false,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
decoration: const BoxDecoration(color: Color(0xB3000000)),
child: Row(
children: [
_FooterTextButton(label: '取消', onTap: _handleCancel),
Expanded(
child: Center(
child: GestureDetector(
onTap: _handleRotate,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 20.w,
vertical: 8.h,
),
decoration: BoxDecoration(
color: const Color(0x26FFFFFF),
borderRadius: BorderRadius.circular(30.r),
),
child: Text(
'↻ 旋转90°',
style: TextStyle(
fontSize: 14.sp,
color: Colors.white,
),
),
),
),
),
),
_FooterTextButton(label: '完成', primary: true, onTap: _handleConfirm),
],
),
),
);
}
}
class _FooterTextButton extends StatelessWidget {
const _FooterTextButton({
required this.label,
required this.onTap,
this.primary = false,
});
final String label;
final VoidCallback onTap;
final bool primary;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
child: Text(
label,
style: TextStyle(
fontSize: 15.sp,
color: primary ? const Color(0xFF2979FF) : Colors.white,
fontWeight: primary ? FontWeight.bold : FontWeight.normal,
),
),
),
);
}
}
/// 旋转 isolate 任务
typedef _RotateTask = ({Uint8List bytes, int angle, bool isPng});
/// 把旋转角度烘焙进 [processed] 的字节(失败时保留原图)
Future<ProcessedImage> _bakeRotation(
ProcessedImage processed,
int angle,
) async {
final isPng = processed.contentType == 'image/png';
final rotated = await compute<_RotateTask, Uint8List?>(
_rotateInIsolate,
(bytes: processed.bytes, angle: angle, isPng: isPng),
);
if (rotated == null || rotated.isEmpty) return processed;
return ProcessedImage(
bytes: rotated,
extension: isPng ? 'png' : 'jpg',
contentType: isPng ? 'image/png' : 'image/jpeg',
originalBytes: processed.originalBytes,
processedBytes: rotated.length,
);
}
/// isolate 内:解码 → 顺时针旋转 → 编码(JPEG 质量 90,对齐 Vue canvas 0.9)
Uint8List? _rotateInIsolate(_RotateTask task) {
final src = img.decodeImage(task.bytes);
if (src == null) return null;
final dst = img.copyRotate(src, angle: task.angle);
final encoded =
task.isPng ? img.encodePng(dst) : img.encodeJpg(dst, quality: 90);
return Uint8List.fromList(encoded);
}