From 40bd6e4d4dc22da76953ab9b3755ba00168b7545 Mon Sep 17 00:00:00 2001
From: hs <873121290@qq.com>
Date: Fri, 29 Aug 2025 09:52:48 +0800
Subject: [PATCH] =?UTF-8?q?bug=E4=BF=AE=E5=A4=8D=E6=9A=82=E5=AD=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
android/app/build.gradle.kts | 42 +-
assets/map/test_baidu_map.html | 2 +-
ios/Podfile.lock | 6 +
ios/Runner/Info.plist | 176 +++---
lib/customWidget/big_video_viewer.dart | 12 +-
lib/customWidget/full_screen_video_page.dart | 130 ++++-
lib/customWidget/photo_picker_row.dart | 264 +++++----
.../picker/CupertinoDatePicker.dart | 184 +++---
lib/customWidget/video_player_widget.dart | 536 ++++++++++++------
lib/http/ApiService.dart | 136 +++--
lib/http/HttpManager.dart | 120 ++--
lib/main.dart | 9 +-
.../check_record_detail_page.dart | 1 +
lib/pages/app/danger_wait_list_page.dart | 2 +-
.../app/hidden_danger_acceptance_page.dart | 2 +
lib/pages/app/hidden_record_detail_page.dart | 2 +
.../pending_rectification_detail_page.dart | 12 +-
lib/pages/home/NFC/home_nfc_add_page.dart | 5 +-
.../home/NFC/home_nfc_check_danger_page.dart | 173 ++++--
lib/pages/home/NFC/home_nfc_detail_page.dart | 168 +++---
lib/pages/home/NFC/home_nfc_list_page.dart | 310 +++++-----
.../home/NFC/nfc_check_danger_detail.dart | 181 ++++--
lib/pages/home/NFC/nfc_question_fecebook.dart | 2 +-
.../home/study/study_class_list_page.dart | 38 +-
lib/pages/home/study/study_detail_page.dart | 248 ++++----
lib/pages/home/study/study_my_task_page.dart | 52 +-
lib/pages/home/study/study_practise_page.dart | 9 +-
lib/pages/home/study/take_exam_page.dart | 22 +-
lib/pages/home/tap/item_list_widget.dart | 6 +-
.../special_wrok/MeasuresListWidget.dart | 326 +++++++----
.../dh_work/HotWorkDetailFormWidget.dart | 1 +
lib/services/auth_service.dart | 3 +
lib/tools/VideoConverter.dart | 47 ++
lib/tools/tools.dart | 8 +-
pubspec.lock | 356 ++++++------
pubspec.yaml | 3 +-
36 files changed, 2216 insertions(+), 1378 deletions(-)
create mode 100644 lib/tools/VideoConverter.dart
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index b24a9e8..3f71f7d 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -36,27 +36,27 @@ android {
versionName = flutter.versionName
}
-// // ✅ 添加 release 签名配置
-// signingConfigs {
-// create("release") {
-// storeFile = file(keystoreProperties["storeFile"] as String)
-// storePassword = keystoreProperties["storePassword"] as String
-// keyAlias = keystoreProperties["keyAlias"] as String
-// keyPassword = keystoreProperties["keyPassword"] as String
-// }
-// }
-//
-// buildTypes {
-// release {
-// // ✅ 替换成 release 签名
-// signingConfig = signingConfigs.getByName("release")
-// isMinifyEnabled = false
-// isShrinkResources = false
-// }
-// debug {
-// signingConfig = signingConfigs.getByName("debug")
-// }
-// }
+ // ✅ 添加 release 签名配置
+ signingConfigs {
+ create("release") {
+ storeFile = file(keystoreProperties["storeFile"] as String)
+ storePassword = keystoreProperties["storePassword"] as String
+ keyAlias = keystoreProperties["keyAlias"] as String
+ keyPassword = keystoreProperties["keyPassword"] as String
+ }
+ }
+
+ buildTypes {
+ release {
+ // ✅ 替换成 release 签名
+ signingConfig = signingConfigs.getByName("release")
+ isMinifyEnabled = false
+ isShrinkResources = false
+ }
+ debug {
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
}
flutter {
diff --git a/assets/map/test_baidu_map.html b/assets/map/test_baidu_map.html
index bd5fd19..010ecac 100644
--- a/assets/map/test_baidu_map.html
+++ b/assets/map/test_baidu_map.html
@@ -227,7 +227,7 @@
const x = [e.latlng.lng, e.latlng.lat];
let inside = (GSON_LON_LAT_BD09 && GSON_LON_LAT_BD09.length > 0) ? isPointInPolygon(x, GSON_LON_LAT_BD09) : true;
if (!inside) {
- alert("当前选择点位不在区域中!");
+ //alert("当前选择点位不在区域中!");
notifyHost({type:'point_selected', ok:false, reason:'out_of_polygon', lng:e.latlng.lng, lat:e.latlng.lat});
} else {
map.addOverlay(marker);
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index e510ae3..17732bc 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -47,6 +47,8 @@ PODS:
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
+ - video_compress (0.3.0):
+ - Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
@@ -75,6 +77,7 @@ DEPENDENCIES:
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
+ - video_compress (from `.symlinks/plugins/video_compress/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
@@ -120,6 +123,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
+ video_compress:
+ :path: ".symlinks/plugins/video_compress/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
wakelock_plus:
@@ -147,6 +152,7 @@ SPEC CHECKSUMS:
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
+ video_compress: f2133a07762889d67f0711ac831faa26f956980e
video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index ee7a908..74a4f8e 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -1,94 +1,94 @@
-
- CADisableMinimumFrameDurationOnPhone
-
- CFBundleDisplayName
- ${PRODUCT_NAME}
- CFBundleExecutable
- $(EXECUTABLE_NAME)
- CFBundleIdentifier
- $(PRODUCT_BUNDLE_IDENTIFIER)
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleName
- 智守安全
- CFBundlePackageType
- APPL
- CFBundleShortVersionString
- $(FLUTTER_BUILD_NAME)
- CFBundleSignature
- ????
- CFBundleVersion
- $(FLUTTER_BUILD_NUMBER)
- LSRequiresIPhoneOS
-
- NFCReaderUsageDescription
- 需要NFC权限来读取和写入标签
- NSAppTransportSecurity
- NSAllowsArbitraryLoads
+ CADisableMinimumFrameDurationOnPhone
+ CFBundleDisplayName
+ ${PRODUCT_NAME}
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ 智守安全
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ NFCReaderUsageDescription
+ 需要NFC权限来读取和写入标签
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+ NSBluetoothAlwaysUsageDescription
+ app需要蓝牙权限连接设备
+ NSCameraUsageDescription
+ app需要相机权限来扫描二维码
+ NSContactsUsageDescription
+ app需要通讯录权限添加好友
+ NSHealthShareUsageDescription
+ app需要读取健康数据
+ NSHealthUpdateUsageDescription
+ app需要写入健康数据
+ NSLocalNetworkUsageDescription
+ app需要发现本地网络设备
+ NSLocationAlwaysAndWhenInUseUsageDescription
+ app需要后台定位以实现持续跟踪
+ NSLocationAlwaysUsageDescription
+ 需要位置权限以提供定位服务
+ NSLocationWhenInUseUsageDescription
+ 需要位置权限以提供定位服务
+ NSMicrophoneUsageDescription
+ app需要麦克风权限进行语音通话
+ NSMotionUsageDescription
+ app需要访问运动数据统计步数
+ NSPhotoLibraryAddUsageDescription
+ app需要保存图片到相册
+ NSPhotoLibraryUsageDescription
+ app需要访问相册以上传图片
+ NSUserNotificationsUsageDescription
+ app需要发送通知提醒重要信息
+ UIApplicationSupportsIndirectInputEvents
+
+ UIBackgroundModes
+
+ remote-notification
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UIStatusBarHidden
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ com.apple.developer.nfc.readersession.formats
+
+ NDEF
+ TAG
+
- NSBluetoothAlwaysUsageDescription
- app需要蓝牙权限连接设备
- NSCameraUsageDescription
- app需要相机权限来扫描二维码
- NSContactsUsageDescription
- app需要通讯录权限添加好友
- NSHealthShareUsageDescription
- app需要读取健康数据
- NSHealthUpdateUsageDescription
- app需要写入健康数据
- NSLocalNetworkUsageDescription
- app需要发现本地网络设备
- NSLocationAlwaysAndWhenInUseUsageDescription
- app需要后台定位以实现持续跟踪
- NSLocationAlwaysUsageDescription
- 需要位置权限以提供定位服务
- NSLocationWhenInUseUsageDescription
- 需要位置权限以提供定位服务
- NSMicrophoneUsageDescription
- app需要麦克风权限进行语音通话
- NSMotionUsageDescription
- app需要访问运动数据统计步数
- NSPhotoLibraryAddUsageDescription
- app需要保存图片到相册
- NSPhotoLibraryUsageDescription
- app需要访问相册以上传图片
- NSUserNotificationsUsageDescription
- app需要发送通知提醒重要信息
- UIApplicationSupportsIndirectInputEvents
-
- UIBackgroundModes
-
- remote-notification
-
- UILaunchStoryboardName
- LaunchScreen
- UIMainStoryboardFile
- Main
- UIStatusBarHidden
-
- UISupportedInterfaceOrientations
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- UISupportedInterfaceOrientations~ipad
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- com.apple.developer.nfc.readersession.formats
-
- NDEF
- TAG
-
-
-
+
\ No newline at end of file
diff --git a/lib/customWidget/big_video_viewer.dart b/lib/customWidget/big_video_viewer.dart
index 9f12746..68d0000 100644
--- a/lib/customWidget/big_video_viewer.dart
+++ b/lib/customWidget/big_video_viewer.dart
@@ -50,14 +50,14 @@ class _BigVideoViewerState extends State {
appBar: MyAppbar(
backgroundColor: Colors.transparent, title: '',
),
- body: Center(
+ body: SafeArea(child: Center(
child: VideoPlayerWidget(
- allowSeek: false,
- controller: _videoController,
- coverUrl:"",
- aspectRatio: _videoController?.value.aspectRatio ?? 16/9,
+ allowSeek: false,
+ controller: _videoController,
+ coverUrl:"",
+ aspectRatio: _videoController?.value.aspectRatio ?? 16/9,
),
- ),
+ ),)
);
}
}
diff --git a/lib/customWidget/full_screen_video_page.dart b/lib/customWidget/full_screen_video_page.dart
index d71d8e2..c14b3d4 100644
--- a/lib/customWidget/full_screen_video_page.dart
+++ b/lib/customWidget/full_screen_video_page.dart
@@ -1,8 +1,9 @@
+import 'dart:io';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'package:chewie/chewie.dart';
-/// 弹窗组件:Chewie 版 VideoPlayerPopup
+/// 弹窗组件:支持 本地文件 / 网络视频 自动识别
class VideoPlayerPopup extends StatefulWidget {
final String videoUrl;
const VideoPlayerPopup({Key? key, required this.videoUrl}) : super(key: key);
@@ -14,27 +15,84 @@ class VideoPlayerPopup extends StatefulWidget {
class _VideoPlayerPopupState extends State {
late VideoPlayerController _videoController;
ChewieController? _chewieController;
+ bool _isNetwork = false;
+ bool _initializing = true;
+ String? _error;
@override
void initState() {
super.initState();
- _videoController = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl))
- ..initialize().then((_) {
- setState(() {});
- });
+ _initController();
+ }
- _chewieController = ChewieController(
- videoPlayerController: _videoController,
- autoPlay: true,
- looping: false,
- showOptions:false,
- allowFullScreen: true,
- allowPlaybackSpeedChanging: true,
- allowMuting: true,
- showControlsOnInitialize: true,
- materialProgressColors: ChewieProgressColors(playedColor: Colors.blue,backgroundColor: Colors.white, handleColor:Colors.blue,bufferedColor:Colors.red),
- aspectRatio: _videoController.value.aspectRatio,
- );
+ Future _initController() async {
+ try {
+ final uri = Uri.tryParse(widget.videoUrl);
+ final scheme = uri?.scheme?.toLowerCase() ?? '';
+
+ // 判定是否网络视频(包括 http/https/rtsp/rtmp)
+ _isNetwork = (scheme == 'http' || scheme == 'https' || scheme == 'rtsp' || scheme == 'rtmp');
+
+ if (_isNetwork) {
+ // 网络视频
+ _videoController = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl));
+ } else {
+ // 本地视频:支持 file:// 开头或直接是本地路径
+ if (scheme == 'file' && uri != null) {
+ _videoController = VideoPlayerController.file(File(uri.toFilePath()));
+ } else {
+ // 直接当做本地路径处理(例如 /storage/... 或 沙盒内路径)
+ _videoController = VideoPlayerController.file(File(widget.videoUrl));
+ }
+ }
+
+ // 初始化 VideoPlayerController
+ await _videoController.initialize();
+
+ // 在视频初始化完成后创建 ChewieController(以确保 aspectRatio 可用)
+ _chewieController?.dispose();
+ _chewieController = ChewieController(
+ videoPlayerController: _videoController,
+ autoPlay: true,
+ looping: false,
+ showOptions: false,
+ allowFullScreen: true,
+ allowPlaybackSpeedChanging: true,
+ allowMuting: true,
+ showControlsOnInitialize: true,
+ // 不要在这里强制颜色(你可以自定义),但保留示例:
+ materialProgressColors: ChewieProgressColors(
+ playedColor: Colors.blue,
+ backgroundColor: Colors.white,
+ handleColor: Colors.blue,
+ bufferedColor: Colors.red,
+ ),
+ aspectRatio: _videoController.value.aspectRatio > 0
+ ? _videoController.value.aspectRatio
+ : 16 / 9,
+ errorBuilder: (context, errorMessage) {
+ return Center(
+ child: Text(
+ errorMessage ?? '视频播放错误',
+ style: const TextStyle(color: Colors.white),
+ ),
+ );
+ },
+ );
+
+ if (!mounted) return;
+ setState(() {
+ _initializing = false;
+ });
+ } catch (e, st) {
+ // 捕获异常并展示错误信息
+ debugPrint('Video init error: $e\n$st');
+ if (!mounted) return;
+ setState(() {
+ _initializing = false;
+ _error = e.toString();
+ });
+ }
}
@override
@@ -60,12 +118,10 @@ class _VideoPlayerPopupState extends State {
),
child: Stack(
children: [
- // 视频播放器
- if (_chewieController != null &&
- _videoController.value.isInitialized)
- Chewie(controller: _chewieController!)
- else
- const Center(child: CircularProgressIndicator()),
+ // 视频播放器 或 错误 / 加载指示
+ Positioned.fill(
+ child: _buildPlayerBody(),
+ ),
// 关闭按钮
Positioned(
@@ -73,7 +129,9 @@ class _VideoPlayerPopupState extends State {
right: 4,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
- onPressed: () => Navigator.of(context).pop(),
+ onPressed: () {
+ Navigator.of(context).pop();
+ },
),
),
],
@@ -82,4 +140,28 @@ class _VideoPlayerPopupState extends State {
),
);
}
+
+ Widget _buildPlayerBody() {
+ if (_initializing) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ if (_error != null) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Text(
+ '播放失败:$_error',
+ style: const TextStyle(color: Colors.white),
+ ),
+ ),
+ );
+ }
+
+ if (_chewieController != null && _videoController.value.isInitialized) {
+ return Chewie(controller: _chewieController!);
+ }
+
+ return const Center(child: Text('无法播放视频', style: TextStyle(color: Colors.white)));
+ }
}
diff --git a/lib/customWidget/photo_picker_row.dart b/lib/customWidget/photo_picker_row.dart
index da6bba1..ae510bc 100644
--- a/lib/customWidget/photo_picker_row.dart
+++ b/lib/customWidget/photo_picker_row.dart
@@ -1,9 +1,12 @@
import 'dart:io';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
+import 'package:qhd_prevention/tools/VideoConverter.dart';
+import 'package:video_compress/video_compress.dart';
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
import 'package:photo_manager/photo_manager.dart';
-
+import 'package:path/path.dart' as p;
import 'ItemWidgetFactory.dart';
/// 媒体选择类型
@@ -22,7 +25,6 @@ class MediaPickerRow extends StatefulWidget {
final bool isEdit; // 新增:控制编辑状态
final bool isCamera; // 新增:只能拍照
-
const MediaPickerRow({
Key? key,
this.maxCount = 4,
@@ -43,6 +45,7 @@ class MediaPickerRow extends StatefulWidget {
class _MediaPickerGridState extends State {
final ImagePicker _picker = ImagePicker();
late List _mediaPaths;
+ bool _isProcessing = false; // 转码或处理时显示 loading
@override
void initState() {
@@ -56,16 +59,81 @@ class _MediaPickerGridState extends State {
);
});
}
- Future _cameraAction() async {
- XFile? picked = await _picker.pickImage(source: ImageSource.camera);
- if (picked != null) {
- final path = picked.path;
- setState(() => _mediaPaths.add(path));
- widget.onChanged(_mediaPaths.map((p) => File(p)).toList());
- widget.onMediaAdded?.call(path);
+ // 公共:当得到本地媒体路径时(可能是 mov/avi 等),需要在这里统一处理(转码、入队、回调)
+ Future _handlePickedPath(String path) async {
+ if (!mounted) return;
+ if (path.isEmpty) return;
+
+ try {
+ String finalPath = path;
+
+ // 如果是视频并且不是 mp4,则调用 video_compress 转码
+ if (widget.mediaType == MediaType.video) {
+ final ext = p.extension(path).toLowerCase();
+ if (ext != '.mp4') {
+ setState(() => _isProcessing = true);
+ try {
+ final info = await VideoCompress.compressVideo(
+ path,
+ quality: VideoQuality.MediumQuality,
+ deleteOrigin: false,
+ );
+ if (info != null && info.file != null) {
+ finalPath = info.file!.path;
+ debugPrint('✅ 转换完成: $path -> $finalPath');
+ } else {
+ throw Exception("转码失败: 返回空文件");
+ }
+ } catch (e) {
+ debugPrint('❌ 视频转码失败: $e');
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('视频转码失败: ${e.toString()}')),
+ );
+ }
+ return;
+ } finally {
+ if (mounted) setState(() => _isProcessing = false);
+ }
+ }
+ }
+
+ // 添加到列表
+ if (_mediaPaths.length < widget.maxCount) {
+ setState(() => _mediaPaths.add(finalPath));
+ widget.onChanged(_mediaPaths.map((p) => File(p)).toList());
+ widget.onMediaAdded?.call(finalPath);
+ }
+ } catch (e) {
+ debugPrint('处理选中媒体失败: $e');
}
}
+
+ Future _cameraAction() async {
+ if (!widget.isEdit || _mediaPaths.length >= widget.maxCount) return;
+
+ try {
+ if (widget.mediaType == MediaType.image) {
+ XFile? picked = await _picker.pickImage(source: ImageSource.camera);
+ if (picked != null) {
+ final path = picked.path;
+ setState(() => _mediaPaths.add(path));
+ widget.onChanged(_mediaPaths.map((p) => File(p)).toList());
+ widget.onMediaAdded?.call(path);
+ }
+ } else {
+ // video from camera
+ XFile? picked = await _picker.pickVideo(source: ImageSource.camera);
+ if (picked != null) {
+ await _handlePickedPath(picked.path);
+ }
+ }
+ } catch (e) {
+ debugPrint('拍摄失败: $e');
+ }
+ }
+
Future _showPickerOptions() async {
if (!widget.isEdit) return; // 不可编辑时直接返回
@@ -78,13 +146,9 @@ class _MediaPickerGridState extends State {
ListTile(
titleAlignment: ListTileTitleAlignment.center,
leading: Icon(
- widget.mediaType == MediaType.image
- ? Icons.camera_alt
- : Icons.videocam,
- ),
- title: Text(
- widget.mediaType == MediaType.image ? '拍照' : '拍摄视频',
+ widget.mediaType == MediaType.image ? Icons.camera_alt : Icons.videocam,
),
+ title: Text(widget.mediaType == MediaType.image ? '拍照' : '拍摄视频'),
onTap: () {
Navigator.of(context).pop();
_pickCamera();
@@ -93,15 +157,9 @@ class _MediaPickerGridState extends State {
ListTile(
titleAlignment: ListTileTitleAlignment.center,
leading: Icon(
- widget.mediaType == MediaType.image
- ? Icons.photo_library
- : Icons.video_library,
- ),
- title: Text(
- widget.mediaType == MediaType.image
- ? '从相册选择'
- : '从相册选择视频',
+ widget.mediaType == MediaType.image ? Icons.photo_library : Icons.video_library,
),
+ title: Text(widget.mediaType == MediaType.image ? '从相册选择' : '从相册选择视频'),
onTap: () {
Navigator.of(context).pop();
_pickGallery();
@@ -126,14 +184,17 @@ class _MediaPickerGridState extends State {
XFile? picked;
if (widget.mediaType == MediaType.image) {
picked = await _picker.pickImage(source: ImageSource.camera);
+ if (picked != null) {
+ final path = picked.path;
+ setState(() => _mediaPaths.add(path));
+ widget.onChanged(_mediaPaths.map((p) => File(p)).toList());
+ widget.onMediaAdded?.call(path);
+ }
} else {
picked = await _picker.pickVideo(source: ImageSource.camera);
- }
- if (picked != null) {
- final path = picked.path;
- setState(() => _mediaPaths.add(path));
- widget.onChanged(_mediaPaths.map((p) => File(p)).toList());
- widget.onMediaAdded?.call(path);
+ if (picked != null) {
+ await _handlePickedPath(picked.path);
+ }
}
} catch (e) {
debugPrint('拍摄失败: $e');
@@ -156,9 +217,7 @@ class _MediaPickerGridState extends State {
final List? assets = await AssetPicker.pickAssets(
context,
pickerConfig: AssetPickerConfig(
- requestType: widget.mediaType == MediaType.image
- ? RequestType.image
- : RequestType.video,
+ requestType: widget.mediaType == MediaType.image ? RequestType.image : RequestType.video,
maxAssets: remaining,
gridCount: 4,
),
@@ -169,8 +228,8 @@ class _MediaPickerGridState extends State {
final file = await asset.file;
if (file != null) {
final path = file.path;
- _mediaPaths.add(path);
- widget.onMediaAdded?.call(path);
+ // 交给统一处理(会转码视频)
+ await _handlePickedPath(path);
}
}
setState(() {});
@@ -195,75 +254,89 @@ class _MediaPickerGridState extends State {
final showAddButton = widget.isEdit && _mediaPaths.length < widget.maxCount;
final itemCount = _mediaPaths.length + (showAddButton ? 1 : 0);
- return GridView.builder(
- shrinkWrap: true,
- physics: const NeverScrollableScrollPhysics(),
- gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
- crossAxisCount: 4,
- crossAxisSpacing: 8,
- mainAxisSpacing: 8,
- childAspectRatio: 1,
- // mainAxisExtent: 80,
- ),
- itemCount: itemCount,
- itemBuilder: (context, index) {
- // 显示媒体项
- if (index < _mediaPaths.length) {
- final path = _mediaPaths[index];
- final isNetwork = path.startsWith('http');
+ return Stack(
+ children: [
+ GridView.builder(
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: 4,
+ crossAxisSpacing: 8,
+ mainAxisSpacing: 8,
+ childAspectRatio: 1,
+ ),
+ itemCount: itemCount,
+ itemBuilder: (context, index) {
+ // 显示媒体项
+ if (index < _mediaPaths.length) {
+ final path = _mediaPaths[index];
+ final isNetwork = path.startsWith('http');
- return GestureDetector(
- onTap: () => widget.onMediaTapped?.call(path),
- child: Stack(
- children: [
- ClipRRect(
- borderRadius: BorderRadius.circular(5),
- child: widget.mediaType == MediaType.image
- ? (isNetwork
- ? Image.network(path, fit: BoxFit.cover, width: 80, height: 80)
- : Image.file(File(path), width: 80, height: 80, fit: BoxFit.cover))
- : Container(
- color: Colors.black12,
- child: const Center(
- child: Icon(
- Icons.videocam,
- color: Colors.white70,
+ return GestureDetector(
+ onTap: () => widget.onMediaTapped?.call(path),
+ child: Stack(
+ children: [
+ ClipRRect(
+ borderRadius: BorderRadius.circular(5),
+ child: widget.mediaType == MediaType.image
+ ? (isNetwork
+ ? Image.network(path, fit: BoxFit.cover, width: 80, height: 80)
+ : Image.file(File(path), width: 80, height: 80, fit: BoxFit.cover))
+ : Container(
+ color: Colors.black12,
+ child: const Center(
+ child: Icon(
+ Icons.videocam,
+ color: Colors.white70,
+ ),
+ ),
),
),
+ // 只在可编辑状态下显示删除按钮
+ if (widget.isEdit)
+ Positioned(
+ top: -15,
+ right: -15,
+ child: IconButton(
+ icon: const Icon(Icons.cancel, size: 20, color: Colors.red),
+ onPressed: () => _removeMedia(index),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+ // 显示添加按钮
+ else if (showAddButton) {
+ return GestureDetector(
+ onTap: widget.isCamera ? _cameraAction : _showPickerOptions,
+ child: Container(
+ decoration: BoxDecoration(
+ border: Border.all(color: Colors.black12),
+ borderRadius: BorderRadius.circular(5),
+ ),
+ child: const Center(
+ child: Icon(Icons.camera_alt, color: Colors.black26),
),
),
- // 只在可编辑状态下显示删除按钮
- if (widget.isEdit)
- Positioned(
- top: -15,
- right: -15,
- child: IconButton(
- icon: const Icon(Icons.cancel, size: 20, color: Colors.red),
- onPressed: () => _removeMedia(index),
- ),
- ),
- ],
- ),
- );
- }
- // 显示添加按钮
- else if (showAddButton) {
- return GestureDetector(
- onTap: widget.isCamera?_cameraAction:_showPickerOptions,
+ );
+ } else {
+ return const SizedBox.shrink();
+ }
+ },
+ ),
+
+ // 转码/处理 loading 遮罩
+ if (_isProcessing)
+ Positioned.fill(
child: Container(
- decoration: BoxDecoration(
- border: Border.all(color: Colors.black12),
- borderRadius: BorderRadius.circular(5),
- ),
+ color: Colors.transparent,
child: const Center(
- child: Icon(Icons.camera_alt, color: Colors.black26),
+ child: CircularProgressIndicator(),
),
),
- );
- } else {
- return const SizedBox.shrink();
- }
- },
+ ),
+ ],
);
}
}
@@ -287,7 +360,6 @@ class RepairedPhotoSection extends StatefulWidget {
final bool isEdit; // 新增:控制编辑状态
final bool isCamera; // 新增:只能拍照
-
const RepairedPhotoSection({
Key? key,
this.maxCount = 4,
@@ -388,4 +460,4 @@ class _RepairedPhotoSectionState extends State {
),
);
}
-}
\ No newline at end of file
+}
diff --git a/lib/customWidget/picker/CupertinoDatePicker.dart b/lib/customWidget/picker/CupertinoDatePicker.dart
index 4d1b2d3..765d5fc 100644
--- a/lib/customWidget/picker/CupertinoDatePicker.dart
+++ b/lib/customWidget/picker/CupertinoDatePicker.dart
@@ -4,17 +4,21 @@ import 'package:flutter/material.dart';
/// 调用示例:
/// DateTime? picked = await BottomDateTimePicker.showDate(
/// context,
-/// allowFuture: true, // 添加此参数允许选择未来时间
-/// minTimeStr: '2025-08-20 08:30', // 可选:不允许选择早于此时间
+/// mode: BottomPickerMode.date, // 或 BottomPickerMode.dateTime(默认)
+/// allowFuture: true,
+/// minTimeStr: '2025-08-20 08:30',
/// );
/// if (picked != null) {
/// print('用户选择的时间:$picked');
/// }
+enum BottomPickerMode { dateTime, date }
+
class BottomDateTimePicker {
static Future showDate(
BuildContext context, {
bool allowFuture = false,
- String? minTimeStr, // 新增:可选起始时间格式 'yyyy-MM-dd HH:mm'
+ String? minTimeStr, // 可选:'yyyy-MM-dd HH:mm'
+ BottomPickerMode mode = BottomPickerMode.dateTime,
}) {
return showModalBottomSheet(
context: context,
@@ -26,19 +30,22 @@ class BottomDateTimePicker {
builder: (_) => _InlineDateTimePickerContent(
allowFuture: allowFuture,
minTimeStr: minTimeStr,
+ mode: mode,
),
);
}
}
class _InlineDateTimePickerContent extends StatefulWidget {
- final bool allowFuture; // 允许未来
- final String? minTimeStr; // 新增:最小允许时间字符串 'yyyy-MM-dd HH:mm'
+ final bool allowFuture;
+ final String? minTimeStr;
+ final BottomPickerMode mode;
const _InlineDateTimePickerContent({
Key? key,
this.allowFuture = false,
this.minTimeStr,
+ this.mode = BottomPickerMode.dateTime,
}) : super(key: key);
@override
@@ -80,13 +87,18 @@ class _InlineDateTimePickerContentState
// 解析 minTimeStr(若提供)
_minTime = _parseMinTime(widget.minTimeStr);
- // 选择初始时间:取 DateTime.now() 与 _minTime 的较大者(确保初始选中合法)
+ // 初始时间:取 now 与 _minTime 的较大者
final now = DateTime.now();
DateTime initial = now;
if (_minTime != null && _minTime!.isAfter(initial)) {
initial = _minTime!;
}
+ // 如果是 date 模式,只保留日期部分(时分归零)
+ if (widget.mode == BottomPickerMode.date) {
+ initial = DateTime(initial.year, initial.month, initial.day);
+ }
+
selectedYear = initial.year;
selectedMonth = initial.month;
selectedDay = initial.day;
@@ -99,12 +111,16 @@ class _InlineDateTimePickerContentState
// controllers 初始项索引需在范围内
yearCtrl = FixedExtentScrollController(
initialItem: years.indexOf(selectedYear).clamp(0, years.length - 1));
- monthCtrl = FixedExtentScrollController(initialItem: (selectedMonth - 1).clamp(0, months.length - 1));
- dayCtrl = FixedExtentScrollController(initialItem: (selectedDay - 1).clamp(0, days.length - 1));
- hourCtrl = FixedExtentScrollController(initialItem: selectedHour.clamp(0, hours.length - 1));
- minuteCtrl = FixedExtentScrollController(initialItem: selectedMinute.clamp(0, minutes.length - 1));
+ monthCtrl = FixedExtentScrollController(
+ initialItem: (selectedMonth - 1).clamp(0, months.length - 1));
+ dayCtrl = FixedExtentScrollController(
+ initialItem: (selectedDay - 1).clamp(0, days.length - 1));
+ hourCtrl = FixedExtentScrollController(
+ initialItem: selectedHour.clamp(0, hours.length - 1));
+ minuteCtrl = FixedExtentScrollController(
+ initialItem: selectedMinute.clamp(0, minutes.length - 1));
- // 如果初始时间小于 minTime(理论上不会,因为我们取了较大者),再修正一次
+ // 确保初始选择满足约束(例如 minTime 或禁止未来)
WidgetsBinding.instance.addPostFrameCallback((_) {
_enforceConstraintsAndUpdateControllers();
});
@@ -122,7 +138,8 @@ class _InlineDateTimePickerContentState
try {
final trimmed = s.trim();
final parts = trimmed.split(' ');
- final dateParts = parts[0].split('-').map((e) => int.parse(e)).toList();
+ final dateParts =
+ parts[0].split('-').map((e) => int.parse(e)).toList();
final timeParts = (parts.length > 1)
? parts[1].split(':').map((e) => int.parse(e)).toList()
: [0, 0];
@@ -133,7 +150,6 @@ class _InlineDateTimePickerContentState
final minute = (timeParts.length > 1) ? timeParts[1] : 0;
return DateTime(year, month, day, hour, minute);
} catch (e) {
- // 解析失败则忽略
debugPrint('parseMinTime failed for "$s": $e');
return null;
}
@@ -153,49 +169,72 @@ class _InlineDateTimePickerContentState
});
}
- // 检查并限制时间:
- // 1) 优先检查 _minTime(如果存在),不允许早于 _minTime
- // 2) 如果没有 _minTime,则根据 allowFuture 决定是否限制到 now
- // 注意:_minTime 优先级高于 allowFuture(如果 _minTime 在未来,会选择 _minTime)
+ // 检查并限制时间(模式感知)
void _enforceConstraintsAndUpdateControllers() {
- final picked = DateTime(selectedYear, selectedMonth, selectedDay, selectedHour, selectedMinute);
+ final now = DateTime.now();
+ final isDateOnly = widget.mode == BottomPickerMode.date;
- // 1) 最小时间约束(优先)
- if (_minTime != null && picked.isBefore(_minTime!)) {
- final m = _minTime!;
- selectedYear = m.year;
- selectedMonth = m.month;
- selectedDay = m.day;
- selectedHour = m.hour;
- selectedMinute = m.minute;
+ final DateTime picked = isDateOnly
+ ? DateTime(selectedYear, selectedMonth, selectedDay)
+ : DateTime(
+ selectedYear, selectedMonth, selectedDay, selectedHour, selectedMinute);
- // 更新天数列表与控制器索引
- _updateDays(jumpDay: false);
- yearCtrl.jumpToItem(years.indexOf(selectedYear));
- monthCtrl.jumpToItem(selectedMonth - 1);
- dayCtrl.jumpToItem(selectedDay - 1);
- hourCtrl.jumpToItem(selectedHour);
- minuteCtrl.jumpToItem(selectedMinute);
- return;
+ // 处理 _minTime(如果存在),在 date 模式下只比较日期部分
+ if (_minTime != null) {
+ final DateTime minRef = isDateOnly
+ ? DateTime(_minTime!.year, _minTime!.month, _minTime!.day)
+ : _minTime!;
+ if (picked.isBefore(minRef)) {
+ // 把选中项调整为 minRef
+ selectedYear = minRef.year;
+ selectedMonth = minRef.month;
+ selectedDay = minRef.day;
+ if (!isDateOnly) {
+ selectedHour = minRef.hour;
+ selectedMinute = minRef.minute;
+ } else {
+ selectedHour = 0;
+ selectedMinute = 0;
+ }
+
+ // 更新天数及控制器
+ _updateDays(jumpDay: false);
+ yearCtrl.jumpToItem(years.indexOf(selectedYear));
+ monthCtrl.jumpToItem(selectedMonth - 1);
+ dayCtrl.jumpToItem(selectedDay - 1);
+ if (!isDateOnly) {
+ hourCtrl.jumpToItem(selectedHour);
+ minuteCtrl.jumpToItem(selectedMinute);
+ }
+ return;
+ }
}
- // 2) 禁止选择未来(当 allowFuture == false 且没有 minTime 或 minTime <= now)
+ // 处理禁止选择未来(当 allowFuture == false)
if (!widget.allowFuture) {
- final now = DateTime.now();
- // 如果 minTime 存在并大于 now,我们已在上面处理(minTime 优先),所以这里处理的是普通情况
- if (picked.isAfter(now)) {
- selectedYear = now.year;
- selectedMonth = now.month;
- selectedDay = now.day;
- selectedHour = now.hour;
- selectedMinute = now.minute;
+ final DateTime nowRef = isDateOnly
+ ? DateTime(now.year, now.month, now.day)
+ : now;
+ if (picked.isAfter(nowRef)) {
+ selectedYear = nowRef.year;
+ selectedMonth = nowRef.month;
+ selectedDay = nowRef.day;
+ if (!isDateOnly) {
+ selectedHour = nowRef.hour;
+ selectedMinute = nowRef.minute;
+ } else {
+ selectedHour = 0;
+ selectedMinute = 0;
+ }
_updateDays(jumpDay: false);
yearCtrl.jumpToItem(years.indexOf(selectedYear));
monthCtrl.jumpToItem(selectedMonth - 1);
dayCtrl.jumpToItem(selectedDay - 1);
- hourCtrl.jumpToItem(selectedHour);
- minuteCtrl.jumpToItem(selectedMinute);
+ if (!isDateOnly) {
+ hourCtrl.jumpToItem(selectedHour);
+ minuteCtrl.jumpToItem(selectedMinute);
+ }
return;
}
}
@@ -213,8 +252,9 @@ class _InlineDateTimePickerContentState
@override
Widget build(BuildContext context) {
+ final isDateOnly = widget.mode == BottomPickerMode.date;
return SizedBox(
- height: 330,
+ height: isDateOnly ? 280 : 330,
child: Column(
children: [
// 顶部按钮
@@ -229,7 +269,9 @@ class _InlineDateTimePickerContentState
),
TextButton(
onPressed: () {
- final result = DateTime(
+ final result = isDateOnly
+ ? DateTime(selectedYear, selectedMonth, selectedDay)
+ : DateTime(
selectedYear,
selectedMonth,
selectedDay,
@@ -245,7 +287,7 @@ class _InlineDateTimePickerContentState
),
const Divider(height: 1),
- // 五列数字滚轮
+ // 可见的滚轮列(date 模式只显示 年 月 日)
Expanded(
child: Row(
children: [
@@ -281,7 +323,6 @@ class _InlineDateTimePickerContentState
items: days.map((e) => e.toString().padLeft(2, '0')).toList(),
onSelected: (idx) {
setState(() {
- // 防护:idx 可能超出当前 days 长度(极小概率)
final safeIdx = idx.clamp(0, days.length - 1);
selectedDay = days[safeIdx];
_enforceConstraintsAndUpdateControllers();
@@ -289,29 +330,30 @@ class _InlineDateTimePickerContentState
},
),
- // 时
- _buildPicker(
- controller: hourCtrl,
- items: hours.map((e) => e.toString().padLeft(2, '0')).toList(),
- onSelected: (idx) {
- setState(() {
- selectedHour = hours[idx];
- _enforceConstraintsAndUpdateControllers();
- });
- },
- ),
+ // 若不是 dateOnly,则显示时分两列
+ if (!isDateOnly)
+ _buildPicker(
+ controller: hourCtrl,
+ items: hours.map((e) => e.toString().padLeft(2, '0')).toList(),
+ onSelected: (idx) {
+ setState(() {
+ selectedHour = hours[idx];
+ _enforceConstraintsAndUpdateControllers();
+ });
+ },
+ ),
- // 分
- _buildPicker(
- controller: minuteCtrl,
- items: minutes.map((e) => e.toString().padLeft(2, '0')).toList(),
- onSelected: (idx) {
- setState(() {
- selectedMinute = minutes[idx];
- _enforceConstraintsAndUpdateControllers();
- });
- },
- ),
+ if (!isDateOnly)
+ _buildPicker(
+ controller: minuteCtrl,
+ items: minutes.map((e) => e.toString().padLeft(2, '0')).toList(),
+ onSelected: (idx) {
+ setState(() {
+ selectedMinute = minutes[idx];
+ _enforceConstraintsAndUpdateControllers();
+ });
+ },
+ ),
],
),
),
diff --git a/lib/customWidget/video_player_widget.dart b/lib/customWidget/video_player_widget.dart
index dfc13a8..99c2507 100644
--- a/lib/customWidget/video_player_widget.dart
+++ b/lib/customWidget/video_player_widget.dart
@@ -3,6 +3,7 @@ import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
+import 'package:qhd_prevention/tools/tools.dart';
import 'package:video_player/video_player.dart';
class VideoPlayerWidget extends StatefulWidget {
@@ -28,57 +29,121 @@ class VideoPlayerWidget extends StatefulWidget {
class _VideoPlayerWidgetState extends State {
bool _visibleControls = true;
Timer? _hideTimer;
- late Timer _positionTimer;
+ Timer? _positionTimer;
Duration _currentPosition = Duration.zero;
Duration _totalDuration = Duration.zero;
bool _isPlaying = false;
- final ValueNotifier _sliderValue = ValueNotifier(0.0);
+ final ValueNotifier _sliderValue = ValueNotifier(0.0);
+
+ // Helpers to avoid accessing controller after it's disposed
+ bool get _hasController => widget.controller != null;
+
+ /// Safe check whether controller is initialized without throwing.
+ bool _controllerInitializedSafe() {
+ try {
+ final c = widget.controller;
+ if (c == null) return false;
+ return c.value.isInitialized;
+ } catch (e) {
+ return false;
+ }
+ }
@override
void initState() {
super.initState();
+ // start hide timer immediately
_startHideTimer();
- _startPositionTimer();
- if (widget.controller != null) {
- widget.controller!.addListener(_controllerListener);
- if (widget.controller!.value.isInitialized) {
- _updateControllerValues();
- }
+ // start position timer only if controller exists and initialized
+ _maybeStartPositionTimer();
+ // add listener if controller present
+ _addControllerListenerSafely();
+ // if controller already initialized, fetch values
+ if (_controllerInitializedSafe()) {
+ _updateControllerValuesSafe();
}
}
@override
void didUpdateWidget(covariant VideoPlayerWidget oldWidget) {
super.didUpdateWidget(oldWidget);
- if (widget.controller != oldWidget.controller) {
- oldWidget.controller?.removeListener(_controllerListener);
+
+ // controller changed -> update listeners and timers
+ if (oldWidget.controller != widget.controller) {
+ _removeControllerListenerSafely(oldWidget.controller);
+ _addControllerListenerSafely();
+ _restartPositionTimer();
+ _updateControllerValuesSafe();
+ }
+ }
+
+ void _addControllerListenerSafely() {
+ try {
widget.controller?.addListener(_controllerListener);
- _updateControllerValues();
+ } catch (_) {
+ // ignore if controller already disposed
+ }
+ }
+
+ void _removeControllerListenerSafely([VideoPlayerController? ctrl]) {
+ final c = ctrl ?? widget.controller;
+ if (c == null) return;
+ try {
+ c.removeListener(_controllerListener);
+ } catch (_) {
+ // ignore
}
}
void _controllerListener() {
if (!mounted) return;
- _updateControllerValues();
+ _updateControllerValuesSafe();
}
- void _updateControllerValues() {
- final c = widget.controller!;
- setState(() {
- _isPlaying = c.value.isPlaying;
- _totalDuration = c.value.duration;
- _currentPosition = c.value.position;
- _sliderValue.value = _currentPosition.inMilliseconds.toDouble();
- });
- }
+ void _updateControllerValuesSafe() {
+ final c = widget.controller;
+ if (c == null) {
+ // clear values
+ if (mounted) {
+ setState(() {
+ _isPlaying = false;
+ _totalDuration = Duration.zero;
+ _currentPosition = Duration.zero;
+ _sliderValue.value = 0;
+ });
+ }
+ return;
+ }
- @override
- void dispose() {
- _hideTimer?.cancel();
- _positionTimer.cancel();
- _sliderValue.dispose();
- widget.controller?.removeListener(_controllerListener);
- super.dispose();
+ try {
+ final value = c.value;
+ if (!value.isInitialized) {
+ if (mounted) {
+ setState(() {
+ _isPlaying = false;
+ _totalDuration = Duration.zero;
+ _currentPosition = Duration.zero;
+ _sliderValue.value = 0;
+ });
+ }
+ return;
+ }
+
+ final pos = value.position;
+ final dur = value.duration;
+ final playing = value.isPlaying;
+
+ if (mounted) {
+ setState(() {
+ _isPlaying = playing;
+ _totalDuration = dur;
+ _currentPosition = pos;
+ _sliderValue.value = pos.inMilliseconds.toDouble();
+ });
+ }
+ } catch (e) {
+ // If controller was disposed between checks, ignore
+ }
}
void _startHideTimer() {
@@ -88,44 +153,80 @@ class _VideoPlayerWidgetState extends State {
});
}
- void _startPositionTimer() {
- _positionTimer = Timer.periodic(const Duration(milliseconds: 200), (_) {
- if (!mounted ||
- widget.controller == null ||
- !widget.controller!.value.isInitialized) return;
- setState(() {
+ void _maybeStartPositionTimer() {
+ if (_positionTimer != null && _positionTimer!.isActive) return;
+ // only start if controller exists
+ if (!_hasController) return;
+ _positionTimer = Timer.periodic(const Duration(milliseconds: 300), (_) {
+ if (!mounted) return;
+ if (!_controllerInitializedSafe()) return;
+ try {
final c = widget.controller!;
- _currentPosition = c.value.position;
- _totalDuration = c.value.duration;
- _isPlaying = c.value.isPlaying;
- _sliderValue.value = _currentPosition.inMilliseconds.toDouble();
- });
+ final pos = c.value.position;
+ final dur = c.value.duration;
+ final playing = c.value.isPlaying;
+ setState(() {
+ _currentPosition = pos;
+ _totalDuration = dur;
+ _isPlaying = playing;
+ _sliderValue.value = pos.inMilliseconds.toDouble();
+ });
+ } catch (_) {
+ // ignore if controller disposed mid-tick
+ }
});
}
+ void _restartPositionTimer() {
+ _positionTimer?.cancel();
+ _maybeStartPositionTimer();
+ }
+
+ void _stopPositionTimer() {
+ try {
+ _positionTimer?.cancel();
+ } catch (_) {}
+ _positionTimer = null;
+ }
+
void _toggleControls() {
setState(() => _visibleControls = !_visibleControls);
- if (_visibleControls) _startHideTimer();
- else _hideTimer?.cancel();
+ if (_visibleControls)
+ _startHideTimer();
+ else
+ _hideTimer?.cancel();
}
void _togglePlayPause() {
- if (widget.controller == null) return;
- setState(() => _isPlaying = !_isPlaying);
- if (_isPlaying) widget.controller!.play();
- else widget.controller!.pause();
- _startHideTimer();
+ final c = widget.controller;
+ if (c == null) return;
+ try {
+ if (_isPlaying) {
+ c.pause();
+ setState(() => _isPlaying = false);
+ } else {
+ c.play();
+ setState(() => _isPlaying = true);
+ }
+ _startHideTimer();
+ } catch (_) {
+ // ignore if controller disposed
+ }
}
- void _enterFullScreen() {
- SystemChrome.setPreferredOrientations([
- DeviceOrientation.landscapeLeft,
- DeviceOrientation.landscapeRight,
- ]);
- SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
+ Future _enterFullScreen() async {
+ // prepare full screen orientation
+ try {
+ await SystemChrome.setPreferredOrientations([
+ DeviceOrientation.landscapeLeft,
+ DeviceOrientation.landscapeRight,
+ ]);
+ await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
+ await NativeOrientation.setLandscape();
+ } catch (_) {}
- Navigator.of(context)
- .push(
+ // push a new page with the same widget but isFullScreen = true
+ await Navigator.of(context).push(
MaterialPageRoute(
builder: (ctx) => Scaffold(
backgroundColor: Colors.black,
@@ -145,153 +246,95 @@ class _VideoPlayerWidgetState extends State {
),
),
),
- )
- .then((_) {
- SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
- SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
- });
+ );
+
+ // on return, restore orientation and UI and force rebuild
+ try {
+ await NativeOrientation.setPortrait();
+ await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
+ await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
+ } catch (_) {}
+
+ if (!mounted) return;
+ // force a rebuild so parent layouts recalc (fixes overflow after exit)
+ setState(() {});
+ }
+
+ @override
+ void dispose() {
+ _hideTimer?.cancel();
+ _stopPositionTimer();
+ _sliderValue.dispose();
+ _removeControllerListenerSafely();
+ super.dispose();
}
@override
Widget build(BuildContext context) {
- final screenW = MediaQuery.of(context).size.width;
- final containerW = widget.isFullScreen ? double.infinity : screenW;
+ // Compute constrained size so widget won't try to be infinite height in non-fullscreen usage.
+ final media = MediaQuery.of(context);
+ final screenW = media.size.width;
+ final screenH = media.size.height;
+
+ // Non-fullscreen preferred height: based on aspect ratio but not exceeding a fraction of screen height
+ final preferredNonFullHeight = min(screenW / (widget.aspectRatio <= 0 ? (16 / 9) : widget.aspectRatio),
+ screenH * 0.5); // at most 50% of screen height
+
+ // If isFullScreen, use available height minus safe areas; otherwise use preferredNonFullHeight
+ final containerW = widget.isFullScreen ? screenW : screenW;
final containerH = widget.isFullScreen
- ? double.infinity
- : containerW / widget.aspectRatio;
+ ? screenH
+ : preferredNonFullHeight;
return Center(
child: SizedBox(
width: containerW,
height: containerH,
child: GestureDetector(
- behavior: HitTestBehavior.translucent, // ← 允许空白区域也响应
+ behavior: HitTestBehavior.translucent,
onTap: _toggleControls,
child: Stack(
fit: StackFit.expand,
children: [
- // 视频或封面
- if (widget.controller != null &&
- widget.controller!.value.isInitialized)
- FittedBox(
- fit: BoxFit.contain,
- alignment: Alignment.center,
- child: SizedBox(
- width: widget.controller!.value.size.width,
- height: widget.controller!.value.size.height,
- child: VideoPlayer(widget.controller!),
- ),
- )
- else
- if (widget.coverUrl.length > 0)
- Image.network(
- widget.coverUrl,
- fit: BoxFit.cover,
- width: containerW,
- height: containerH,
- ),
-
- // 控制栏
- if (_visibleControls)
+ // Video or cover: use AspectRatio to avoid forcing Column to expand
+ _buildVideoOrCover(containerW, containerH),
+ if (widget.isFullScreen)
Positioned(
- bottom: 0,
- left: 0,
- right: 0,
- child: Container(
- height: 50,
- decoration: BoxDecoration(
- gradient: LinearGradient(
- begin: Alignment.bottomCenter,
- end: Alignment.topCenter,
- colors: [
- Colors.black.withOpacity(0.7),
- Colors.transparent
- ],
- ),
- ),
- padding: const EdgeInsets.symmetric(horizontal: 12),
- child: Row(
- children: [
- IconButton(
- padding: EdgeInsets.zero,
- icon: Icon(
- _isPlaying ? Icons.pause : Icons.play_arrow,
- size: 28,
- color: Colors.white,
- ),
- onPressed: _togglePlayPause,
- ),
- Expanded(
- child: ValueListenableBuilder(
- valueListenable: _sliderValue,
- builder: (_, value, __) => SliderTheme(
- data: SliderTheme.of(context).copyWith(
- activeTrackColor: Colors.white,
- inactiveTrackColor: Colors.white54,
- thumbColor: Colors.white,
- overlayColor: Colors.white24,
- trackHeight: 2,
- thumbShape: RoundSliderThumbShape(
- enabledThumbRadius: 8),
- ),
- child: SliderTheme(
- data: SliderTheme.of(context).copyWith(
- activeTrackColor: Colors.white, // 活跃轨道颜色
- inactiveTrackColor: Colors.grey[400],// 非活跃轨道颜色
- thumbColor: Colors.white, // 滑块颜色
- overlayColor: Colors.white.withAlpha(0x33), // 滑块按下外圈
- disabledActiveTrackColor: Colors.white, // 禁用时也用同样的活跃轨道
- disabledInactiveTrackColor: Colors.grey[400],
- disabledThumbColor: Colors.white,
- ),
- child: Slider(
- value: value,
- min: 0,
- max: _totalDuration.inMilliseconds.toDouble(),
- // 不管 allowSeek 如何,都不改变 onChanged
- onChanged: (v) {
- if (widget.allowSeek && widget.controller != null) {
- widget.controller!.seekTo(Duration(milliseconds: v.toInt()));
- setState(() => _currentPosition = Duration(milliseconds: v.toInt()));
- _sliderValue.value = v;
- _startHideTimer();
- }
- },
- ),
- ),
- ),
- ),
- ),
- SizedBox(
- width: 110,
- child: Text(
- '${_formatDuration(_currentPosition)} / ${_formatDuration(_totalDuration)}',
- style: TextStyle(
- color: Colors.white,
- fontSize: 12,
- fontFeatures: [FontFeature.tabularFigures()],
- ),
- ),
- ),
- IconButton(
- padding: EdgeInsets.zero,
- icon: Icon(
- widget.isFullScreen
- ? Icons.fullscreen_exit
- : Icons.fullscreen,
- size: 28,
- color: Colors.white,
- ),
- onPressed: () {
- widget.isFullScreen
- ? Navigator.of(context).pop()
- : _enterFullScreen();
+ top: MediaQuery.of(context).padding.top + 8, // 考虑刘海/状态栏高度
+ left: 8,
+ child: SafeArea(
+ top: true,
+ bottom: false,
+ child: ClipOval(
+ child: Material(
+ color: Colors.black38, // 背景(半透明),可按需调整
+ child: InkWell(
+ onTap: () async {
+ // 可选:在 pop 之前恢复方向与系统 UI(也可以只 pop,让上层的 .then 处理恢复)
+ try {
+ await NativeOrientation.setPortrait();
+ } catch (_) {}
+ try {
+ await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
+ await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
+ } catch (_) {}
+ Navigator.of(context).maybePop();
},
+ child: const Padding(
+ padding: EdgeInsets.all(8.0),
+ child: Icon(
+ Icons.arrow_back,
+ color: Colors.white,
+ size: 22,
+ ),
+ ),
),
- ],
+ ),
),
),
),
+ // Controls overlay
+ if (_visibleControls) _buildControls(),
],
),
),
@@ -299,6 +342,139 @@ class _VideoPlayerWidgetState extends State {
);
}
+ Widget _buildVideoOrCover(double containerW, double containerH) {
+ final c = widget.controller;
+
+ // If controller exists and is initialized (safe check), show video in AspectRatio
+ if (c != null) {
+ try {
+ if (c.value.isInitialized) {
+ // Use video's natural aspect ratio if available, otherwise widget.aspectRatio
+ final vidAspect = (c.value.size.height > 0) ? (c.value.size.width / c.value.size.height) : widget.aspectRatio;
+ return Center(
+ child: AspectRatio(
+ aspectRatio: vidAspect > 0 ? vidAspect : widget.aspectRatio,
+ child: VideoPlayer(c),
+ ),
+ );
+ }
+ } catch (_) {
+ // controller may be disposed; fall through to show cover
+ }
+ }
+
+ // Fallback: show cover image (fills the container)
+ if (widget.coverUrl.isNotEmpty) {
+ return Image.network(
+ widget.coverUrl,
+ width: containerW,
+ height: containerH,
+ fit: BoxFit.cover,
+ errorBuilder: (_, __, ___) => Container(color: Colors.black),
+ );
+ }
+
+ // final fallback: black box
+ return Container(color: Colors.black);
+ }
+
+ Widget _buildControls() {
+ // Use local copies to avoid repeated reads that might throw if ctrl disposed
+ final totalMs = _totalDuration.inMilliseconds.toDouble();
+ final sliderMax = totalMs > 0 ? totalMs : 1.0;
+ final sliderValue = _sliderValue.value.clamp(0.0, sliderMax).toDouble();
+
+ return Positioned(
+ bottom: 0,
+ left: 0,
+ right: 0,
+ child: Container(
+ height: 50,
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.bottomCenter,
+ end: Alignment.topCenter,
+ colors: [
+ Colors.black.withOpacity(0.7),
+ Colors.transparent,
+ ],
+ ),
+ ),
+ padding: const EdgeInsets.symmetric(horizontal: 12),
+ child: Row(
+ children: [
+ IconButton(
+ padding: EdgeInsets.zero,
+ icon: Icon(
+ _isPlaying ? Icons.pause : Icons.play_arrow,
+ size: 28,
+ color: Colors.white,
+ ),
+ onPressed: _togglePlayPause,
+ ),
+ Expanded(
+ child: ValueListenableBuilder(
+ valueListenable: _sliderValue,
+ builder: (_, value, __) => SliderTheme(
+ data: SliderTheme.of(context).copyWith(
+ activeTrackColor: Colors.white,
+ inactiveTrackColor: Colors.white54,
+ thumbColor: Colors.white,
+ overlayColor: Colors.white24,
+ trackHeight: 2,
+ thumbShape: RoundSliderThumbShape(enabledThumbRadius: 8),
+ ),
+ child: Slider(
+ value: sliderValue,
+ min: 0,
+ max: sliderMax,
+ onChanged: (v) {
+ if (widget.allowSeek && widget.controller != null) {
+ try {
+ widget.controller!.seekTo(Duration(milliseconds: v.toInt()));
+ setState(() => _currentPosition = Duration(milliseconds: v.toInt()));
+ _sliderValue.value = v;
+ _startHideTimer();
+ } catch (_) {}
+ }
+ },
+ ),
+ ),
+ ),
+ ),
+ SizedBox(
+ width: 110,
+ child: Text(
+ '${_formatDuration(_currentPosition)} / ${_formatDuration(_totalDuration)}',
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 12,
+ fontFeatures: [FontFeature.tabularFigures()],
+ ),
+ ),
+ ),
+ IconButton(
+ padding: EdgeInsets.zero,
+ icon: Icon(
+ widget.isFullScreen ? Icons.fullscreen_exit : Icons.fullscreen,
+ size: 28,
+ color: Colors.white,
+ ),
+ onPressed: () {
+ if (widget.isFullScreen) {
+ // if this widget is used inside a full screen route, popping will exit fullscreen
+ Navigator.of(context).maybePop();
+ } else {
+ _enterFullScreen();
+ }
+ },
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
String _formatDuration(Duration d) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
final h = d.inHours, m = d.inMinutes.remainder(60), s = d.inSeconds.remainder(60);
diff --git a/lib/http/ApiService.dart b/lib/http/ApiService.dart
index 95c3ecd..eb4f9c5 100644
--- a/lib/http/ApiService.dart
+++ b/lib/http/ApiService.dart
@@ -20,10 +20,11 @@ class ApiService {
// static const String publicKey = 'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDUoHAavCikaZxjlDM6Km8cX+ye78F4oF39AcEfnE1p2Yn9pJ9WFxYZ4Vkh6F8SKMi7k4nYsKceqB1RwG996SvHQ5C3pM3nbXCP4K15ad6QhN4a7lzlbLhiJcyIKszvvK8ncUDw8mVQ0j/2mwxv05yH6LN9OKU6Hzm1ninpWeE+awIDAQAB'
/// 人脸识别服务
static const String baseFacePath =
- "https://qaaqwh.qhdsafety.com/whb_stu_face/";
+ "https://qaaqwh.qhdsafety.com/whb_stu_face";
/// 登录及其他管理后台接口
static const String basePath = "https://qaaqwh.qhdsafety.com/integrated_whb";
+ // static const String basePath = "http://192.168.0.37:8099/api";
/// 图片文件服务
static const String baseImgPath = "https://file.zcloudchina.com/YTHFile";
@@ -36,8 +37,6 @@ class ApiService {
static const String projectManagerUrl =
'https://pm.qhdsafety.com/zy-projectManage';
- /// NFC巡检接口
- static const String baseNFCPath = "http://192.168.0.37:8099/api/app";
// /// 人脸识别服务
// static const String baseFacePath =
@@ -357,6 +356,19 @@ U6Hzm1ninpWeE+awIDAQAB
},
);
}
+ static Future