diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 74a78b9..622dbbc 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,13 @@ + + + + + + + ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + NSCameraUsageDescription + 示例需要使用相机拍照后上传到 OSS。 + NSPhotoLibraryUsageDescription + 示例需要访问相册以选择图片进行上传和预览。 + NSPhotoLibraryAddUsageDescription + 示例需要将长按查看的图片保存到系统相册。 LSRequiresIPhoneOS UILaunchStoryboardName diff --git a/example/lib/main.dart b/example/lib/main.dart index bf6f2c9..37bcde1 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_common/flutter_common.dart'; import 'package:get/get.dart'; @@ -14,6 +15,7 @@ class MyApp extends StatelessWidget { return GetMaterialApp( title: 'flutter_common Demo', debugShowCheckedModeBanner: false, + builder: EasyLoading.init(), theme: ThemeData( scaffoldBackgroundColor: const Color(0xFFF5F7FB), colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF4D6FD5)), @@ -36,8 +38,89 @@ class MyHomePage extends StatefulWidget { } class _MyHomePageState extends State { + static const String _ossAccessKeyId = 'LTAI5tRJh3W4TrfQC3mDK9Vp'; + static const String _ossHost = + 'https://jingheyijia-cop.oss-cn-chengdu.aliyuncs.com'; + static const String _bindHost = 'https://static.cop.jingheyijia.com'; + static const String _policy = + 'eyJleHBpcmF0aW9uIjoiMjAyNi0wNi0zMFQyMTo0OTozNVoiLCJjb25kaXRpb25zIjpbWyJzdGFydHMtd2l0aCIsIiRrZXkiLCJ3eGFwcC1tYXAyL3VwbG9hZC8iXV19'; + static const String _signature = 're2VV8BiN5oS2altXSmiTG/Y0wc='; + static const String _ossDirectory = 'wxapp-map2/upload/'; + static const String _callback = + 'eyJjYWxsYmFja1VybCI6Imh0dHBzOi8vdGVzdGluZy52aWN0b3JtZW4uY29tL2FwaS9haXN0b3JlL29zcy9jYWxsYmFjayIsImNhbGxiYWNrQm9keSI6ImZpbGVuYW1lPSR7b2JqZWN0fVx1MDAyNnNpemU9JHtzaXplfVx1MDAyNm1pbWVUeXBlPSR7bWltZVR5cGV9XHUwMDI2aGVpZ2h0PSR7aW1hZ2VJbmZvLmhlaWdodH1cdTAwMjZ3aWR0aD0ke2ltYWdlSW5mby53aWR0aH0iLCJjYWxsYmFja0JvZHlUeXBlIjoiYXBwbGljYXRpb24veC13d3ctZm9ybS11cmxlbmNvZGVkIn0='; + static const String _seedImageUrl = + '$_bindHost/wxapp-map2/upload/moment/2025625/app-yctgxYDwcPkh.jpg'; + String _singleDateText = '未选择'; String _rangeDateText = '未选择'; + String _imageStatus = '已预置 1 张示例图,点击缩略图预览,长按大图保存到相册'; + late List _imageUrls; + + @override + void initState() { + super.initState(); + _imageUrls = [_seedImageUrl]; + } + + Future _uploadImages() async { + await UploadImagesTool.uploadImagesTool( + context: context, + max: 9, + isShowLoading: true, + oSSAccessKeyId: _ossAccessKeyId, + policy: _policy, + callback: _callback, + signature: _signature, + ossDirectory: _ossDirectory, + ossHost: _ossHost, + chooseImagesTap: (list) { + final uploadedUrls = List.from(list as List) + .where((item) => item.isNotEmpty) + .toList(); + if (uploadedUrls.isEmpty) { + return; + } + setState(() { + _imageUrls = [..._imageUrls, ...uploadedUrls]; + _imageStatus = + '已上传 ${uploadedUrls.length} 张图片,当前共 ${_imageUrls.length} 张'; + }); + }, + ); + } + + void _openPreview(int index) { + if (_imageUrls.isEmpty) { + return; + } + + LookImagesTool.lookImages( + listData: _imageUrls, + currentPage: index, + isShowEdit: true, + oSSAccessKeyId: _ossAccessKeyId, + policy: _policy, + callback: _callback, + signature: _signature, + ossDirectory: _ossDirectory, + ossHost: _ossHost, + onEditCallBack: (imageUrl, currentIndex) { + setState(() { + _imageUrls[currentIndex] = imageUrl; + _imageStatus = '第 ${currentIndex + 1} 张图片已重新编辑并上传'; + }); + }, + ); + } + + void _removeImage(int index) { + setState(() { + _imageUrls.removeAt(index); + _imageStatus = _imageUrls.isEmpty + ? '暂无图片,请先上传后再体验预览和下载' + : '已删除 1 张图片,当前剩余 ${_imageUrls.length} 张'; + }); + } @override Widget build(BuildContext context) { @@ -52,6 +135,8 @@ class _MyHomePageState extends State { children: [ _buildIntroCard(), const SizedBox(height: 16), + _buildImageDemoCard(), + const SizedBox(height: 16), _buildDemoCard( title: '单日选择组件', description: '使用 `CalendarChooseWidget` 的单选模式,适合筛选某一天的数据。', @@ -114,8 +199,8 @@ class _MyHomePageState extends State { ), SizedBox(height: 12), Text( - 'lib/calendarcalendar 里提供了日期选择相关组件,' - 'lib/upload_image 聚合图片上传与预览能力,' + 'lib/calendarcalendar 提供日期筛选能力,' + 'lib/upload_image 包含 OSS 上传、图片预览、编辑和下载到相册的能力,' 'lib/utils 则放了日期、弹窗等通用工具。', style: TextStyle( fontSize: 15, @@ -129,6 +214,172 @@ class _MyHomePageState extends State { ); } + Widget _buildImageDemoCard() { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'LookImagesTool 图片预览', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Color(0xFF1A1A1A), + ), + ), + const SizedBox(height: 8), + const Text( + '这个示例使用你提供的 OSS 配置上传图片,点击缩略图进入 `LookImagesTool` 预览,支持编辑后重新上传,也支持长按大图保存到手机相册。', + style: TextStyle( + fontSize: 14, + height: 1.6, + color: Color(0xFF5C6670), + ), + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + const _InfoChip( + label: 'Host', + value: 'jingheyijia-cop.oss-cn-chengdu.aliyuncs.com', + ), + _InfoChip(label: 'Bind', value: _bindHost), + const _InfoChip(label: '目录', value: _ossDirectory), + ], + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _uploadImages, + icon: const Icon(Icons.cloud_upload_outlined), + label: const Text('上传图片到 OSS'), + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (int index = 0; index < _imageUrls.length; index++) + _buildImageTile( + imageUrl: _imageUrls[index], + index: index, + ), + ], + ), + const SizedBox(height: 14), + Text( + _imageStatus, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF4D6FD5), + ), + ), + ], + ), + ), + ); + } + + Widget _buildImageTile({ + required String imageUrl, + required int index, + }) { + return SizedBox( + width: 104, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Material( + color: Colors.white, + child: InkWell( + onTap: () => _openPreview(index), + child: Stack( + children: [ + SizedBox( + width: 104, + height: 104, + child: Image.network( + imageUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) { + return Container( + color: const Color(0xFFF1F4FA), + alignment: Alignment.center, + child: const Icon( + Icons.broken_image_outlined, + color: Color(0xFF7A8694), + ), + ); + }, + ), + ), + Positioned( + left: 8, + top: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + Positioned( + right: 6, + top: 6, + child: GestureDetector( + onTap: () => _removeImage(index), + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.45), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + size: 14, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + '点击预览', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF5C6670), + ), + ), + ], + ), + ); + } + Widget _buildDemoCard({ required String title, required String description, @@ -205,3 +456,39 @@ class _MyHomePageState extends State { )} ${DateTimeUtils.getWeekDay(date)}'; } } + +class _InfoChip extends StatelessWidget { + final String label; + final String value; + + const _InfoChip({ + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFF1F4FA), + borderRadius: BorderRadius.circular(999), + ), + child: RichText( + text: TextSpan( + style: const TextStyle( + fontSize: 12, + color: Color(0xFF5C6670), + ), + children: [ + TextSpan( + text: '$label: ', + style: const TextStyle(fontWeight: FontWeight.w700), + ), + TextSpan(text: value), + ], + ), + ), + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 7864ac3..ef23a81 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -326,7 +326,7 @@ packages: source: path version: "0.0.1" flutter_easyloading: - dependency: transitive + dependency: "direct main" description: name: flutter_easyloading sha256: ba21a3c883544e582f9cc455a4a0907556714e1e9cf0eababfcb600da191d17c diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 6f50b3a..4e170b6 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: image_editor_plus: ^1.0.6 get: ^4.6.5 dio: ^5.9.0 + flutter_easyloading: ^3.0.5 image_picker: ^1.1.0 #相册单选 # native_exif: ^0.6.2 # exif: ^3.3.0 diff --git a/lib/flutter_common.dart b/lib/flutter_common.dart index 24eb7ea..7bfa5d6 100644 --- a/lib/flutter_common.dart +++ b/lib/flutter_common.dart @@ -1,6 +1,6 @@ -library flutter_common; - export 'calendarcalendar/calendar_choose_widget.dart'; export 'calendarcalendar/custom_calendar_range_picker_widget.dart'; export 'calendarcalendar/custom_date_picker.dart'; +export 'upload_image/look_images_widget.dart'; +export 'upload_image/upload_images_tool.dart'; export 'utils/date_utils.dart'; diff --git a/lib/upload_image/down_load_image_tool.dart b/lib/upload_image/down_load_image_tool.dart index 1e7906a..a0529da 100644 --- a/lib/upload_image/down_load_image_tool.dart +++ b/lib/upload_image/down_load_image_tool.dart @@ -11,45 +11,50 @@ import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart'; import 'package:permission_handler/permission_handler.dart'; class DownLoadImageTool { - // 请求照片库权限 static Future requestPhotoPermission() async { - // 检查当前权限状态 - // var status = await Permission.photos.status; - var status = await Permission.storage.request(); - if (status.isDenied) { - // 请求权限 + PermissionStatus status; + if (Platform.isIOS) { + status = await Permission.photosAddOnly.request(); + } else { status = await Permission.photos.request(); - - // 如果用户拒绝了权限,可以显示一个解释 - if (status.isPermanentlyDenied) { - // 打开应用设置,让用户手动启用权限 - await openAppSettings(); - } } - return status.isGranted; + if (status.isPermanentlyDenied) { + await openAppSettings(); + } + + return status.isGranted || status.isLimited; } // 或者请求存储权限(适用于Android) static Future requestStoragePermission() async { + if (Platform.isIOS) { + return requestPhotoPermission(); + } + if (Platform.isAndroid) { // 对于Android 13及以上版本 - if (await DeviceInfoPlugin().androidInfo.then((info) => info.version.sdkInt) >= 33) { + if (await DeviceInfoPlugin() + .androidInfo + .then((info) => info.version.sdkInt) >= + 33) { var status = await Permission.photos.request(); - if(status == PermissionStatus.denied){ - await requestPhotoPermission(); + if (status.isPermanentlyDenied) { + await openAppSettings(); } - return status.isGranted; + return status.isGranted || status.isLimited; } else { // 对于Android 13以下版本 var status = await Permission.storage.request(); + if (status.isPermanentlyDenied) { + await openAppSettings(); + } return status.isGranted; } - } else { - // iOS使用照片权限 - return await requestPhotoPermission(); } + + return false; } ///保存到相册 @@ -98,10 +103,9 @@ class DownLoadImageTool { final result = await ImageGallerySaverPlus.saveImage( Uint8List.fromList(response.data), quality: 60, - name: "hello", + name: "flutter_common_${DateTime.now().millisecondsSinceEpoch}", isReturnImagePathOfIOS: true, ); - print('=result ============ $result'); return result; } @@ -127,7 +131,6 @@ class DownLoadImageTool { EasyLoading.dismiss(); return null; } - } catch (e) { EasyLoading.dismiss(); // debugPrint('Error fetching image: $e'); diff --git a/lib/upload_image/look_images_widget.dart b/lib/upload_image/look_images_widget.dart index 47c1f2c..b735b99 100644 --- a/lib/upload_image/look_images_widget.dart +++ b/lib/upload_image/look_images_widget.dart @@ -3,7 +3,9 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_common/upload_image/down_load_image_tool.dart'; import 'package:flutter_common/upload_image/ossUtil.dart'; +import 'package:flutter_common/utils/toast_utils.dart'; import 'package:get/get.dart'; import 'package:image_editor_plus/image_editor_plus.dart'; import 'package:path_provider/path_provider.dart'; @@ -14,6 +16,7 @@ class LookImagesTool { required List listData, int? currentPage, void Function(String)? onCallBack, + void Function(String, int)? onEditCallBack, String? oSSAccessKeyId, Function? callBack, bool? isShowEdit, @@ -31,6 +34,7 @@ class LookImagesTool { listData: listData, currentPage: currentPage, onCallBack: onCallBack, + onEditCallBack: onEditCallBack, oSSAccessKeyId: oSSAccessKeyId, policy: policy, callback: callback, @@ -52,6 +56,7 @@ class LookImagesWidget extends StatefulWidget { final String? ossDirectory; final String? ossHost; final void Function(String)? onCallBack; + final void Function(String, int)? onEditCallBack; final bool? isShowEdit; const LookImagesWidget({ @@ -65,6 +70,7 @@ class LookImagesWidget extends StatefulWidget { this.ossDirectory, this.ossHost, this.onCallBack, + this.onEditCallBack, this.isShowEdit, }); @@ -73,7 +79,7 @@ class LookImagesWidget extends StatefulWidget { } class _LookImagesWidgetState extends State { - List listData = []; + List listData = []; late int currentPage; late int initialPage = 0; @@ -87,9 +93,11 @@ class _LookImagesWidgetState extends State { options: Options(responseType: ResponseType.bytes), ); // 响应成功且数据非空时,直接转为 Uint8List - return response.statusCode == 200 && response.data != null ? Uint8List.fromList(response.data!) : null; + return response.statusCode == 200 && response.data != null + ? Uint8List.fromList(response.data!) + : null; } catch (e) { - print('图片转换失败:$e'); // 捕获网络错误、URL 非法等异常 + debugPrint('图片转换失败:$e'); // 捕获网络错误、URL 非法等异常 return null; } } @@ -143,11 +151,16 @@ class _LookImagesWidgetState extends State { if (uint8List.length < 4) return "bin"; // 无法识别时返回二进制后缀 // PNG 头:89 50 4E 47 - if (uint8List[0] == 0x89 && uint8List[1] == 0x50 && uint8List[2] == 0x4E && uint8List[3] == 0x47) { + if (uint8List[0] == 0x89 && + uint8List[1] == 0x50 && + uint8List[2] == 0x4E && + uint8List[3] == 0x47) { return "png"; } // JPG 头:FF D8 FF - else if (uint8List[0] == 0xFF && uint8List[1] == 0xD8 && uint8List[2] == 0xFF) { + else if (uint8List[0] == 0xFF && + uint8List[1] == 0xD8 && + uint8List[2] == 0xFF) { return "jpg"; } // MP4 头:00 00 00 18 66 74 79 70 @@ -169,7 +182,8 @@ class _LookImagesWidgetState extends State { } /// Uint8List 转临时 File 并且上传到oss并返回访问路径 - Future uint8ListToTempFile(Uint8List uint8List, {String fileName = "temp_file"}) async { + Future uint8ListToTempFile(Uint8List uint8List, + {String fileName = "temp_file"}) async { if (uint8List.isEmpty) return null; try { // 1. 获取临时存储目录(跨平台兼容) @@ -178,7 +192,8 @@ class _LookImagesWidgetState extends State { String tempPath = tempDir.path; // 2. 拼接文件路径(可自定义后缀,如 .png、.mp4 等) - File tempFile = File("$tempPath/$fileName.${getExtension(uint8List)}"); // 自动识别后缀(可选) + File tempFile = + File("$tempPath/$fileName.${getExtension(uint8List)}"); // 自动识别后缀(可选) // 3. 将 Uint8List 写入文件 await tempFile.writeAsBytes(uint8List); @@ -198,14 +213,29 @@ class _LookImagesWidgetState extends State { // print("上传后的访问路径:$imageUrl"); return imageUrl; } catch (e) { - print("转换临时文件失败:$e"); + debugPrint("转换临时文件失败:$e"); return null; } } + Future _saveCurrentImage() async { + if (listData.isEmpty) { + ToastUtils.showToast(msg: '暂无可保存的图片'); + return; + } + + final result = await DownLoadImageTool.savePhoto( + imageUrl: listData[currentPage], + ); + + if (result != null) { + ToastUtils.showToast(msg: '已保存到相册'); + } + } + @override void initState() { - listData = widget.listData; + listData = List.from(widget.listData); if (widget.currentPage == null) { initialPage = 0; currentPage = 0; @@ -220,40 +250,63 @@ class _LookImagesWidgetState extends State { return Scaffold( body: Stack( children: [ - PhotoViewGallery.builder( - itemCount: listData.length, - pageController: PageController(initialPage: currentPage), - onPageChanged: (index) { - setState(() { - currentPage = index; - }); - }, - builder: (_, index) { - return PhotoViewGalleryPageOptions( - imageProvider: NetworkImage( - listData[index], - ), - ); - }, + GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPress: _saveCurrentImage, + child: PhotoViewGallery.builder( + itemCount: listData.length, + pageController: PageController(initialPage: currentPage), + onPageChanged: (index) { + setState(() { + currentPage = index; + }); + }, + builder: (_, index) { + return PhotoViewGalleryPageOptions( + imageProvider: NetworkImage( + listData[index], + ), + ); + }, + ), ), Positioned( - left: 15, - top: 50, - child: GestureDetector(onTap: () => Get.back(), child: Icon(Icons.arrow_back_ios, color: Colors.white))), + left: 15, + top: 50, + child: GestureDetector( + onTap: () => Get.back(), + child: const Icon(Icons.arrow_back_ios, color: Colors.white), + ), + ), Positioned( - right: 15, - top: 50, - child: GestureDetector( - onTap: () async { - Uint8List? imageFile = await editImage(url: listData[currentPage]); - String? url = await uint8ListToTempFile(imageFile ?? Uint8List(0)); - if(widget.onCallBack != null){ - widget.onCallBack!(url??''); - } - }, - child: Visibility( - visible: widget.isShowEdit??false, - child: Icon(Icons.edit, color: Colors.white)))), + right: 15, + top: 50, + child: GestureDetector( + onTap: () async { + Uint8List? imageFile = + await editImage(url: listData[currentPage]); + if (imageFile == null || imageFile.isEmpty) { + return; + } + + String? url = await uint8ListToTempFile(imageFile); + if (url == null || url.isEmpty) { + ToastUtils.showToast(msg: '图片上传失败'); + return; + } + + setState(() { + listData[currentPage] = url; + }); + widget.onEditCallBack?.call(url, currentPage); + widget.onCallBack?.call(url); + }, + child: Visibility( + visible: widget.isShowEdit ?? false, + child: const Icon(Icons.edit, color: Colors.white), + ), + ), + ), //图片张数指示器 Positioned( left: 0,