Compare commits
2 Commits
9c10669cf8
...
a5a8a7ef34
| Author | SHA1 | Date |
|---|---|---|
|
|
a5a8a7ef34 | |
|
|
40bd6e4d4d |
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,94 +1,94 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>${PRODUCT_NAME}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>智守安全</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NFCReaderUsageDescription</key>
|
||||
<string>需要NFC权限来读取和写入标签</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>${PRODUCT_NAME}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>智守安全</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NFCReaderUsageDescription</key>
|
||||
<string>需要NFC权限来读取和写入标签</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>app需要蓝牙权限连接设备</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>app需要相机权限来扫描二维码</string>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>app需要通讯录权限添加好友</string>
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<string>app需要读取健康数据</string>
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<string>app需要写入健康数据</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>app需要发现本地网络设备</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>app需要后台定位以实现持续跟踪</string>
|
||||
<key>NSLocationAlwaysUsageDescription</key>
|
||||
<string>需要位置权限以提供定位服务</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>需要位置权限以提供定位服务</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>app需要麦克风权限进行语音通话</string>
|
||||
<key>NSMotionUsageDescription</key>
|
||||
<string>app需要访问运动数据统计步数</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>app需要保存图片到相册</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>app需要访问相册以上传图片</string>
|
||||
<key>NSUserNotificationsUsageDescription</key>
|
||||
<string>app需要发送通知提醒重要信息</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>com.apple.developer.nfc.readersession.formats</key>
|
||||
<array>
|
||||
<string>NDEF</string>
|
||||
<string>TAG</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>app需要蓝牙权限连接设备</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>app需要相机权限来扫描二维码</string>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>app需要通讯录权限添加好友</string>
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<string>app需要读取健康数据</string>
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<string>app需要写入健康数据</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>app需要发现本地网络设备</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>app需要后台定位以实现持续跟踪</string>
|
||||
<key>NSLocationAlwaysUsageDescription</key>
|
||||
<string>需要位置权限以提供定位服务</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>需要位置权限以提供定位服务</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>app需要麦克风权限进行语音通话</string>
|
||||
<key>NSMotionUsageDescription</key>
|
||||
<string>app需要访问运动数据统计步数</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>app需要保存图片到相册</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>app需要访问相册以上传图片</string>
|
||||
<key>NSUserNotificationsUsageDescription</key>
|
||||
<string>app需要发送通知提醒重要信息</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>com.apple.developer.nfc.readersession.formats</key>
|
||||
<array>
|
||||
<string>NDEF</string>
|
||||
<string>TAG</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
|
@ -50,14 +50,14 @@ class _BigVideoViewerState extends State<BigVideoViewer> {
|
|||
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,
|
||||
),
|
||||
),
|
||||
),)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<VideoPlayerPopup> {
|
||||
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<void> _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<VideoPlayerPopup> {
|
|||
),
|
||||
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<VideoPlayerPopup> {
|
|||
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<VideoPlayerPopup> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MediaPickerRow> {
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
late List<String> _mediaPaths;
|
||||
bool _isProcessing = false; // 转码或处理时显示 loading
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -56,16 +59,81 @@ class _MediaPickerGridState extends State<MediaPickerRow> {
|
|||
);
|
||||
});
|
||||
}
|
||||
Future<void> _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<void> _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<void> _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<void> _showPickerOptions() async {
|
||||
if (!widget.isEdit) return; // 不可编辑时直接返回
|
||||
|
||||
|
|
@ -78,13 +146,9 @@ class _MediaPickerGridState extends State<MediaPickerRow> {
|
|||
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<MediaPickerRow> {
|
|||
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<MediaPickerRow> {
|
|||
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<MediaPickerRow> {
|
|||
final List<AssetEntity>? 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<MediaPickerRow> {
|
|||
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<MediaPickerRow> {
|
|||
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<RepairedPhotoSection> {
|
|||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DateTime?> 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<DateTime>(
|
||||
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();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<VideoPlayerWidget> {
|
||||
bool _visibleControls = true;
|
||||
Timer? _hideTimer;
|
||||
late Timer _positionTimer;
|
||||
Timer? _positionTimer;
|
||||
Duration _currentPosition = Duration.zero;
|
||||
Duration _totalDuration = Duration.zero;
|
||||
bool _isPlaying = false;
|
||||
final ValueNotifier<double> _sliderValue = ValueNotifier(0.0);
|
||||
final ValueNotifier<double> _sliderValue = ValueNotifier<double>(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<VideoPlayerWidget> {
|
|||
});
|
||||
}
|
||||
|
||||
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<void> _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<VideoPlayerWidget> {
|
|||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.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<double>(
|
||||
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<VideoPlayerWidget> {
|
|||
);
|
||||
}
|
||||
|
||||
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<double>(
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
@ -370,6 +369,19 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
},
|
||||
);
|
||||
}
|
||||
static Future<Map<String, dynamic>> getVideoPermissions() {
|
||||
return HttpManager().request(
|
||||
basePath,
|
||||
'/app/coursestudyvideorecord/getVideoPermissions',
|
||||
method: Method.post,
|
||||
withToken: true,
|
||||
data: {
|
||||
'USERNAME': SessionService.instance.username,
|
||||
'CORPINFO_ID': SessionService.instance.corpinfoId,
|
||||
'USER_ID': SessionService.instance.loginUserId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 学习详情视频列表
|
||||
static Future<Map<String, dynamic>> getStudyDetailList(
|
||||
|
|
@ -392,6 +404,7 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
basePath,
|
||||
'/app/edu/stagestudentrelation/getMyTask',
|
||||
method: Method.post,
|
||||
withToken: true,
|
||||
data: {
|
||||
'CLASSCURRICULUM_ID': CLASSCURRICULUM_ID,
|
||||
'CLASS_ID': CLASS_ID,
|
||||
|
|
@ -410,6 +423,7 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
basePath,
|
||||
'/app/edu/audioOrVideo/getVideoPlayInfoApp',
|
||||
method: Method.post,
|
||||
withToken: true,
|
||||
data: {
|
||||
'VIDEOCOURSEWARE_ID': VIDEOCOURSEWARE_ID,
|
||||
'CORPINFO_ID': SessionService.instance.corpinfoId,
|
||||
|
|
@ -437,6 +451,7 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
baseFacePath,
|
||||
'/app/user/getUserFaceTime',
|
||||
method: Method.post,
|
||||
withToken: true,
|
||||
data: {
|
||||
'loading': false,
|
||||
'FACE_TIME': FACE_TIME,
|
||||
|
|
@ -470,6 +485,7 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
basePath,
|
||||
'/app/edu/coursestudyvideorecord/getVideoProgress',
|
||||
method: Method.post,
|
||||
withToken: true,
|
||||
data: {
|
||||
'VIDEOCOURSEWARE_ID': VIDEOCOURSEWARE_ID,
|
||||
'CURRICULUM_ID': CURRICULUM_ID,
|
||||
|
|
@ -482,34 +498,19 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
}
|
||||
|
||||
/// 上报播放进度或结束
|
||||
static Future<Map<String, dynamic>> fnSubmitPlayTime(
|
||||
String VIDEOCOURSEWARE_ID,
|
||||
String CURRICULUM_ID,
|
||||
String IS_END,
|
||||
int RESOURCETIME,
|
||||
String CHAPTER_ID,
|
||||
String STUDENT_ID,
|
||||
String CLASSCURRICULUM_ID,
|
||||
String CLASS_ID,
|
||||
) {
|
||||
static Future<Map<String, dynamic>> fnSubmitPlayTime(Map data) {
|
||||
return HttpManager().request(
|
||||
basePath,
|
||||
'/app/edu/coursestudyvideorecord/save',
|
||||
method: Method.post,
|
||||
withToken: true,
|
||||
data: {
|
||||
'VIDEOCOURSEWARE_ID': VIDEOCOURSEWARE_ID,
|
||||
'CURRICULUM_ID': CURRICULUM_ID,
|
||||
'CHAPTER_ID': CHAPTER_ID,
|
||||
'RESOURCETIME': RESOURCETIME,
|
||||
'IS_END': IS_END,
|
||||
'CLASS_ID': CLASS_ID,
|
||||
'CLASSCURRICULUM_ID': CLASSCURRICULUM_ID,
|
||||
'STUDENT_ID': STUDENT_ID,
|
||||
'loading': false,
|
||||
...data,
|
||||
'USER_NAME': SessionService.instance.username,
|
||||
'CORPINFO_ID': SessionService.instance.corpinfoId,
|
||||
'USER_ID': SessionService.instance.loginUserId,
|
||||
},
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3710,8 +3711,8 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
/// 管道区域
|
||||
static Future<Map<String, dynamic>> getNfcPipeLineAreaList() {
|
||||
return HttpManager().request(
|
||||
baseNFCPath,
|
||||
'/pipelineInspection/getPipelineAreaListAll',
|
||||
basePath,
|
||||
'/app/pipelineInspection/getPipelineAreaListAll',
|
||||
method: Method.post,
|
||||
data: {"CORPINFO_ID": SessionService.instance.corpinfoId, 'STATUS': '0'},
|
||||
);
|
||||
|
|
@ -3722,8 +3723,8 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
String PIPELINE_AREA_ID,
|
||||
) {
|
||||
return HttpManager().request(
|
||||
baseNFCPath,
|
||||
'/pipelineInspection/getEquipmentPipelineListAll',
|
||||
basePath,
|
||||
'/app/pipelineInspection/getEquipmentPipelineListAll',
|
||||
method: Method.post,
|
||||
data: {
|
||||
"CORPINFO_ID": SessionService.instance.corpinfoId,
|
||||
|
|
@ -3736,8 +3737,8 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
///NFC标签入库
|
||||
static Future<Map<String, dynamic>> nfcTagAdd(Map data) {
|
||||
return HttpManager().request(
|
||||
baseNFCPath,
|
||||
'/pipelineInspection/nfcTagAdd',
|
||||
basePath,
|
||||
'/app/pipelineInspection/nfcTagAdd',
|
||||
method: Method.post,
|
||||
data: {
|
||||
...data,
|
||||
|
|
@ -3753,8 +3754,8 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
int currentPage,
|
||||
) {
|
||||
return HttpManager().request(
|
||||
baseNFCPath,
|
||||
'/pipelineInspection/getPatrolTaskList?showCount=$showCount¤tPage=$currentPage',
|
||||
basePath,
|
||||
'/app/pipelineInspection/getPatrolTaskList?showCount=$showCount¤tPage=$currentPage',
|
||||
|
||||
method: Method.post,
|
||||
data: {
|
||||
|
|
@ -3772,8 +3773,8 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
Map data,
|
||||
) {
|
||||
return HttpManager().request(
|
||||
baseNFCPath,
|
||||
'/pipelineInspection/getPatrolTaskDetailList?showCount=$showCount¤tPage=$currentPage',
|
||||
basePath,
|
||||
'/app/pipelineInspection/getPatrolTaskDetailList?showCount=$showCount¤tPage=$currentPage',
|
||||
method: Method.post,
|
||||
data: {
|
||||
...data,
|
||||
|
|
@ -3785,8 +3786,8 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
|
||||
static Future<Map<String, dynamic>> nfcWriteCheck(String NFC_CODE) {
|
||||
return HttpManager().request(
|
||||
baseNFCPath,
|
||||
'/pipelineInspection/nfcTagCheck',
|
||||
basePath,
|
||||
'/app/pipelineInspection/nfcTagCheck',
|
||||
method: Method.post,
|
||||
data: {
|
||||
"CORPINFO_ID": SessionService.instance.corpinfoId,
|
||||
|
|
@ -3800,8 +3801,8 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
Map data,
|
||||
) async {
|
||||
return HttpManager().request(
|
||||
baseNFCPath,
|
||||
'/pipelineInspection/goEditNfcExceptionRecord',
|
||||
basePath,
|
||||
'/app/pipelineInspection/goEditNfcExceptionRecord',
|
||||
method: Method.post,
|
||||
data: {
|
||||
...data,
|
||||
|
|
@ -3815,8 +3816,8 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
Map data,
|
||||
) async {
|
||||
return HttpManager().request(
|
||||
baseNFCPath,
|
||||
'/pipelineInspection/nfcExceptionRecordAdd',
|
||||
basePath,
|
||||
'/app/pipelineInspection/nfcExceptionRecordAdd',
|
||||
method: Method.post,
|
||||
data: {
|
||||
...data,
|
||||
|
|
@ -3830,8 +3831,8 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
Map data,
|
||||
) async {
|
||||
return HttpManager().request(
|
||||
baseNFCPath,
|
||||
'/pipelineInspection/patrolRecordDetailSaveOrUpdate',
|
||||
basePath,
|
||||
'/app/pipelineInspection/patrolRecordDetailSaveOrUpdate',
|
||||
method: Method.post,
|
||||
data: {
|
||||
...data,
|
||||
|
|
@ -3845,8 +3846,8 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
Map data,
|
||||
) async {
|
||||
return HttpManager().request(
|
||||
baseNFCPath,
|
||||
'/pipelineInspection/goEditPatrolRecordDetailHidden',
|
||||
basePath,
|
||||
'/app/pipelineInspection/goEditPatrolRecordDetailHidden',
|
||||
method: Method.post,
|
||||
data: {
|
||||
...data,
|
||||
|
|
@ -3855,7 +3856,54 @@ U6Hzm1ninpWeE+awIDAQAB
|
|||
},
|
||||
);
|
||||
}
|
||||
// goEditPatrolTaskDetail
|
||||
|
||||
// NFC隐患上传文件接口
|
||||
static Future<Map<String, dynamic>> addNFCImgFiles(
|
||||
String imagePath,
|
||||
Map data,
|
||||
) async {
|
||||
final file = File(imagePath);
|
||||
if (!await file.exists()) {
|
||||
throw ApiException('file_not_found', '图片不存在:$imagePath');
|
||||
}
|
||||
final fileName = file.path.split(Platform.pathSeparator).last;
|
||||
return HttpManager().uploadFaceImage(
|
||||
baseUrl: basePath,
|
||||
path: '/app//imgfiles/add',
|
||||
fromData: {
|
||||
...data,
|
||||
"CORPINFO_ID": SessionService.instance.corpinfoId,
|
||||
"USER_ID": SessionService.instance.loginUserId,
|
||||
'FFILE': await MultipartFile.fromFile(file.path, filename: fileName),
|
||||
},
|
||||
);
|
||||
}
|
||||
/// 删除已上传图片
|
||||
static Future<Map<String, dynamic>> deleteNFCImage(Map data) {
|
||||
return HttpManager().request(
|
||||
basePath,
|
||||
'/app/app/imgfiles/delete',
|
||||
method: Method.post,
|
||||
data: {
|
||||
...data,
|
||||
"CORPINFO_ID": SessionService.instance.corpinfoId,
|
||||
"USER_ID": SessionService.instance.loginUserId,
|
||||
},
|
||||
);
|
||||
}
|
||||
/// nfc巡检记录
|
||||
static Future<Map<String, dynamic>> nfcInspectionRecord(
|
||||
int showCount,
|
||||
int currentPage,
|
||||
) async {
|
||||
return HttpManager().request(
|
||||
basePath,
|
||||
'/app/pipelineInspection/getPatrolRecordList?showCount=$showCount¤tPage=$currentPage',
|
||||
method: Method.post,
|
||||
data: {
|
||||
"CORPINFO_ID": SessionService.instance.corpinfoId,
|
||||
"USER_ID": SessionService.instance.loginUserId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:io';
|
|||
import 'dart:ui';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:qhd_prevention/customWidget/toast_util.dart';
|
||||
import 'package:qhd_prevention/tools/tools.dart';
|
||||
|
||||
/// 全局接口异常
|
||||
class ApiException implements Exception {
|
||||
|
|
@ -42,24 +43,24 @@ class HttpManager {
|
|||
..add(InterceptorsWrapper(onError: (err, handler) {
|
||||
// TODO 暂不处理
|
||||
// 捕获401错误
|
||||
// if (err.response?.statusCode == 401) {
|
||||
// // 触发全局登出回调
|
||||
// onUnauthorized?.call();
|
||||
// // 创建自定义异常
|
||||
// final apiException = ApiException(
|
||||
// '提示',
|
||||
// '您的账号已在其他设备登录,已自动下线'
|
||||
// );
|
||||
// // 直接抛出业务异常,跳过后续错误处理
|
||||
// return handler.reject(
|
||||
// DioException(
|
||||
// requestOptions: err.requestOptions,
|
||||
// error: apiException,
|
||||
// response: err.response,
|
||||
// type: DioExceptionType.badResponse,
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
if (err.response?.statusCode == 401) {
|
||||
// 触发全局登出回调
|
||||
onUnauthorized?.call();
|
||||
// 创建自定义异常
|
||||
final apiException = ApiException(
|
||||
'提示',
|
||||
'您的账号已在其他设备登录,已自动下线'
|
||||
);
|
||||
// 直接抛出业务异常,跳过后续错误处理
|
||||
return handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: apiException,
|
||||
response: err.response,
|
||||
type: DioExceptionType.badResponse,
|
||||
),
|
||||
);
|
||||
}
|
||||
handler.next(err);
|
||||
}));
|
||||
}
|
||||
|
|
@ -72,72 +73,65 @@ class HttpManager {
|
|||
Map<String, dynamic>? data,
|
||||
Map<String, dynamic>? params,
|
||||
CancelToken? cancelToken,
|
||||
bool withToken = false, // 新增可选参数,默认 false
|
||||
}) async {
|
||||
Response resp;
|
||||
final url = baseUrl + path;
|
||||
|
||||
// 动态 headers
|
||||
final headers = {
|
||||
'Content-Type': Headers.formUrlEncodedContentType,
|
||||
};
|
||||
if (withToken) {
|
||||
final token = SessionService.instance.studyToken;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
headers['Token'] = token;
|
||||
}
|
||||
}
|
||||
|
||||
final options = Options(
|
||||
method: method.name.toUpperCase(),
|
||||
contentType: Headers.formUrlEncodedContentType,
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
try {
|
||||
switch (method) {
|
||||
case Method.get:
|
||||
final queryParameters = <String, dynamic>{};
|
||||
if (params != null) queryParameters.addAll(params);
|
||||
if (data != null) queryParameters.addAll(data);
|
||||
resp = await _dio.get(
|
||||
url,
|
||||
queryParameters: queryParameters,
|
||||
cancelToken: cancelToken,
|
||||
options: options,
|
||||
);
|
||||
break;
|
||||
case Method.put:
|
||||
resp = await _dio.put(
|
||||
url,
|
||||
data: data,
|
||||
queryParameters: params,
|
||||
cancelToken: cancelToken,
|
||||
options: options,
|
||||
);
|
||||
break;
|
||||
case Method.delete:
|
||||
resp = await _dio.delete(
|
||||
url,
|
||||
queryParameters: params,
|
||||
cancelToken: cancelToken,
|
||||
options: options,
|
||||
);
|
||||
resp = await _dio.get(url,
|
||||
queryParameters: {...?params, ...?data},
|
||||
cancelToken: cancelToken,
|
||||
options: options);
|
||||
break;
|
||||
case Method.post:
|
||||
resp = await _dio.post(
|
||||
url,
|
||||
data: data,
|
||||
queryParameters: params,
|
||||
cancelToken: cancelToken,
|
||||
options: options,
|
||||
);
|
||||
resp = await _dio.post(url,
|
||||
data: data,
|
||||
queryParameters: params,
|
||||
cancelToken: cancelToken,
|
||||
options: options);
|
||||
break;
|
||||
case Method.put:
|
||||
resp = await _dio.put(url,
|
||||
data: data,
|
||||
queryParameters: params,
|
||||
cancelToken: cancelToken,
|
||||
options: options);
|
||||
break;
|
||||
case Method.delete:
|
||||
resp = await _dio.delete(url,
|
||||
queryParameters: params,
|
||||
cancelToken: cancelToken,
|
||||
options: options);
|
||||
break;
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
// 如果已经是ApiException类型(401转换的)
|
||||
if (e.error is ApiException) {
|
||||
throw e.error as ApiException;
|
||||
}
|
||||
// 其他网络错误
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException('network_error', e.message ?? e.toString());
|
||||
}
|
||||
|
||||
// 解析返回 JSON
|
||||
final json = resp.data is Map<String, dynamic>
|
||||
? resp.data as Map<String, dynamic>
|
||||
: <String, dynamic>{};
|
||||
// final result = json['result'] as String?;
|
||||
// final msg = json['msg'] as String? ?? json['message'] as String? ?? '';
|
||||
// if (result != 'success') {
|
||||
// // 非 success 都抛异常
|
||||
// throw ApiException(result ?? 'unknown', msg);
|
||||
// }
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import 'package:flutter/services.dart'; // for TextInput.hide
|
|||
|
||||
// 全局导航键
|
||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
// 全局路由
|
||||
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
||||
|
||||
// 全局消息控制器
|
||||
class GlobalMessage {
|
||||
|
|
@ -91,7 +93,7 @@ void main() async {
|
|||
);
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
GlobalMessage.showError('会话已过期,请重新登录');
|
||||
GlobalMessage.showError('您的账号已在其他设备登录,已自动下线,请使用单一设备进行学习。');
|
||||
});
|
||||
};
|
||||
// 自动登录逻辑
|
||||
|
|
@ -123,7 +125,7 @@ class MyApp extends StatelessWidget {
|
|||
title: '',
|
||||
navigatorKey: navigatorKey,
|
||||
// 在路由变化时统一取消焦点(防止 push/pop 时焦点回到 TextField)
|
||||
navigatorObservers: [KeyboardUnfocusNavigatorObserver()],
|
||||
navigatorObservers: [KeyboardUnfocusNavigatorObserver(),routeObserver],
|
||||
builder: (context, child) {
|
||||
return EasyLoading.init(
|
||||
builder: (context, widget) {
|
||||
|
|
@ -139,6 +141,9 @@ class MyApp extends StatelessWidget {
|
|||
)(context, child);
|
||||
},
|
||||
theme: ThemeData(
|
||||
textTheme: const TextTheme(
|
||||
bodyMedium: TextStyle(fontSize: 14), // 默认字体大小
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: Colors.black12,
|
||||
thickness: .5,
|
||||
|
|
|
|||
|
|
@ -393,6 +393,7 @@ class _CheckRecordDetailPageState extends State<CheckRecordDetailPage> {
|
|||
// 加载页面(注意:如果 loadRequest 失败你可以捕获异常)
|
||||
try {
|
||||
await _controller!.loadRequest(Uri.parse('http://47.92.102.56:7811/file/fluteightmap/index.html'));
|
||||
|
||||
} catch (e, st) {
|
||||
debugPrint('loadRequest 错误: $e\n$st');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -360,7 +360,7 @@ class _DangerWaitListPageState extends State<DangerWaitListPage> {
|
|||
Future<void> _getDangerRecord(int type, int currentPage,
|
||||
String startDate,String endDate,String level,String riskStandard,String state,
|
||||
String departmentId,String correctiveDepartment,
|
||||
String isIndex,String keyWord,bool loadMore) async {
|
||||
String isIndex,String keyWord,bool loadMore) async {
|
||||
try {
|
||||
if (_isLoading) return;
|
||||
_isLoading = true;
|
||||
|
|
|
|||
|
|
@ -621,6 +621,8 @@ class _HiddenDangerAcceptancePageState extends State<HiddenDangerAcceptancePage>
|
|||
case '3': return '标准排查清单检查';
|
||||
case '4': return '专项检查';
|
||||
case '5': return '安全检查';
|
||||
case '6': return 'NFC设备巡检';
|
||||
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -443,6 +443,8 @@ class _HiddenRecordDetailPageState extends State<HiddenRecordDetailPage> {
|
|||
case '3': return '标准排查清单检查';
|
||||
case '4': return '专项检查';
|
||||
case '5': return '安全检查';
|
||||
case '6': return 'NFC设备巡检';
|
||||
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,10 +94,14 @@ class _PendingRectificationDetailPageState extends State<PendingRectificationDet
|
|||
hs = data['hs'] ?? {};
|
||||
|
||||
// 处理图片和视频
|
||||
for (var img in data['hImgs']) {
|
||||
files.add(img["FILEPATH"]);
|
||||
}
|
||||
videoList = data['hiddenVideo'] ?? [];
|
||||
for (var img in data['hImgs']) {
|
||||
if (img["FILEPATH"].toString().endsWith('.mp4')) {
|
||||
videoList.add(img);
|
||||
}else{
|
||||
files.add(img["FILEPATH"]);
|
||||
}
|
||||
}
|
||||
|
||||
// List<dynamic> filesZheng = data['rImgs'] ?? [];
|
||||
for (var img in data['rImgs']) {
|
||||
|
|
@ -736,6 +740,8 @@ class _PendingRectificationDetailPageState extends State<PendingRectificationDet
|
|||
case '3': return '标准排查清单检查';
|
||||
case '4': return '专项检查';
|
||||
case '5': return '安全检查';
|
||||
case '6': return 'NFC设备巡检';
|
||||
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,9 +199,8 @@ class _HomeNfcAddPageState extends State<HomeNfcAddPage> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: MyAppbar(title: 'NFC标签写入', actions: [
|
||||
TextButton(onPressed: _getNfcData, child: Text('读取',style: TextStyle(color: Colors.white),))
|
||||
],),
|
||||
appBar: MyAppbar(title: 'NFC标签写入'
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(12),
|
||||
|
|
|
|||
|
|
@ -42,13 +42,15 @@ class HomeNfcCheckDangerPage extends StatefulWidget {
|
|||
super.key,
|
||||
required this.info,
|
||||
required this.facebookImages,
|
||||
required this.isNfcError
|
||||
required this.isNfcError,
|
||||
required this.isEdit
|
||||
});
|
||||
|
||||
final Map info;
|
||||
final List<String> facebookImages;
|
||||
// nfc异常上报
|
||||
final bool isNfcError;
|
||||
final bool isEdit;
|
||||
|
||||
@override
|
||||
State<HomeNfcCheckDangerPage> createState() => _HomeNfcCheckDangerPageState();
|
||||
|
|
@ -57,6 +59,7 @@ class HomeNfcCheckDangerPage extends StatefulWidget {
|
|||
class _HomeNfcCheckDangerPageState extends State<HomeNfcCheckDangerPage> {
|
||||
late Map<String, dynamic> pd = {};
|
||||
OptionData? selectType; // 初始为 null(未选择)
|
||||
late bool unchecked = true;
|
||||
|
||||
final List<OptionData> _options = const [
|
||||
OptionData(
|
||||
|
|
@ -82,7 +85,7 @@ class _HomeNfcCheckDangerPageState extends State<HomeNfcCheckDangerPage> {
|
|||
void initState() {
|
||||
// TODO: implement initState
|
||||
super.initState();
|
||||
final bool unchecked = widget.info['INSPECTED_FLAG'] == '0';
|
||||
unchecked = widget.info['INSPECTED_FLAG'] == '0';
|
||||
if (!unchecked) { // 已经巡检过
|
||||
for (OptionData data in _options) {
|
||||
if (data.label == widget.info['INSPECTION_RESULT']) {
|
||||
|
|
@ -101,13 +104,36 @@ class _HomeNfcCheckDangerPageState extends State<HomeNfcCheckDangerPage> {
|
|||
if (result['result'] == 'success') {
|
||||
setState(() {
|
||||
pd = result['pd'];
|
||||
List hImgs = result['hImgs'];
|
||||
List gzImageList = result['rImgs'];
|
||||
List<nfcImgData> imgs = [];
|
||||
List<nfcImgData> videos = [];
|
||||
if (hImgs.isNotEmpty) {
|
||||
for (Map item in hImgs) {
|
||||
String path = item['FILEPATH'];
|
||||
if (path.contains('.mp4')) {
|
||||
videos.add(nfcImgData(path: '${ApiService.baseImgPath}$path', id: item['IMGFILES_ID']));
|
||||
}else{
|
||||
imgs.add(nfcImgData(path: '${ApiService.baseImgPath}$path', id: item['IMGFILES_ID']));
|
||||
}
|
||||
}
|
||||
pd['imgList'] = imgs;
|
||||
pd['videoList'] = videos;
|
||||
}
|
||||
if (gzImageList.isNotEmpty) {
|
||||
List<nfcImgData> zgImgs = [];
|
||||
for (Map item in gzImageList) {
|
||||
String path = item['FILEPATH'];
|
||||
zgImgs.add(nfcImgData(path: '${ApiService.baseImgPath}$path', id: item['IMGFILES_ID']));
|
||||
}
|
||||
pd['gzImageList'] = zgImgs;
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Future<void> _submit() async {
|
||||
// 保护:如果未选择任何选项就不提交
|
||||
// 如果未选择任何选项就不提交
|
||||
if (selectType == null) {
|
||||
// 可选:提示用户选择
|
||||
ToastUtil.showNormal(context, '请先选择检查结果');
|
||||
|
|
@ -115,49 +141,61 @@ class _HomeNfcCheckDangerPageState extends State<HomeNfcCheckDangerPage> {
|
|||
}
|
||||
Map data = {
|
||||
...widget.info,
|
||||
...pd,
|
||||
'INSPECTION_RESULT': selectType?.label ?? '',
|
||||
'TYPE': '0', // 记录类型(0-正常检查记录 1-超期未检查记录)
|
||||
};
|
||||
LoadingDialogHelper.show();
|
||||
// 如果选中的是不合格,需要特殊处理
|
||||
if (selectType?.label == '不合格') {
|
||||
List imgList = pd['imgList'] ?? [];
|
||||
List _videos = pd['videoList'] ?? [];
|
||||
List zgImgList = pd['gzImageList'] ?? [];
|
||||
for (int i = 0; i < imgList.length; i++) {
|
||||
await _reloadFeedBack(imgList[i], '3');
|
||||
}
|
||||
for (int i = 0; i < _videos.length; i++) {
|
||||
await _reloadFeedBack(_videos[i], '3');
|
||||
}
|
||||
for (int i = 0; i < zgImgList.length; i++) {
|
||||
await _reloadFeedBack(zgImgList[i], '4');
|
||||
}
|
||||
data = {...data,...pd};
|
||||
|
||||
}
|
||||
|
||||
if (widget.facebookImages.isNotEmpty) {
|
||||
// 手动上报 nfc 异常图片(按顺序上传)
|
||||
final List<String> uploaded = [];
|
||||
for (int i = 0; i < widget.facebookImages.length; i++) {
|
||||
String imagePath = await _reloadFeedBack(widget.facebookImages[i], '30');
|
||||
if (imagePath.isNotEmpty) {
|
||||
uploaded.add(imagePath);
|
||||
}
|
||||
}
|
||||
if (uploaded.isNotEmpty) {
|
||||
data['PHOTO_URL'] = uploaded.join(',');
|
||||
}
|
||||
}
|
||||
data['CHECK_CONTENT'] = widget.info['INSPECTION_CONTENT'];
|
||||
final result = await ApiService.nfcChekSubmit(data);
|
||||
LoadingDialogHelper.hide();
|
||||
if (result['result'] == 'success') {
|
||||
Map p = result['pd'];
|
||||
String HIDDEN_ID = p['HIDDEN_ID'] ?? '';
|
||||
// 如果选中的是不合格,需要上传相关图片和视频
|
||||
if (selectType?.label == '不合格') {
|
||||
List<nfcImgData> imgList = pd['imgList'] ?? [];
|
||||
List<nfcImgData> _videos = pd['videoList'] ?? [];
|
||||
List<nfcImgData> zgImgList = pd['gzImageList'] ?? [];
|
||||
for (int i = 0; i < imgList.length; i++) {
|
||||
nfcImgData data = imgList[i];
|
||||
if (data.id.isEmpty) {
|
||||
await _reloadFeedBack(imgList[i].path, '3', HIDDEN_ID);
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < _videos.length; i++) {
|
||||
nfcImgData data = _videos[i];
|
||||
if (data.id.isEmpty) {
|
||||
await _reloadFeedBack(_videos[i].path, '3', HIDDEN_ID);
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < zgImgList.length; i++) {
|
||||
nfcImgData data = zgImgList[i];
|
||||
if (data.id.isEmpty) {
|
||||
await _reloadFeedBack(zgImgList[i].path, '4', HIDDEN_ID);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (widget.isNfcError) { // 如果手动检查上传nfc异常,多退一个路由
|
||||
if (widget.facebookImages.isNotEmpty) {
|
||||
// 手动上报 nfc 异常图片(按顺序上传)
|
||||
final List<String> uploaded = [];
|
||||
for (int i = 0; i < widget.facebookImages.length; i++) {
|
||||
String imagePath = await _reloadFeedBack(widget.facebookImages[i], '30', widget.info['EQUIPMENT_PIPELINE_ID']);
|
||||
if (imagePath.isNotEmpty) {
|
||||
uploaded.add(imagePath);
|
||||
}
|
||||
}
|
||||
if (uploaded.isNotEmpty) {
|
||||
data['PHOTO_URL'] = uploaded.join(',');
|
||||
}
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
|
||||
}
|
||||
LoadingDialogHelper.hide();
|
||||
ToastUtil.showSuccess(context, '提交成功');
|
||||
Navigator.of(context).pop();
|
||||
|
||||
} else {
|
||||
|
|
@ -166,13 +204,13 @@ class _HomeNfcCheckDangerPageState extends State<HomeNfcCheckDangerPage> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<String> _reloadFeedBack(String imagePath, String type) async {
|
||||
Future<String> _reloadFeedBack(String imagePath, String type, String FOREIGN_KEY) async {
|
||||
try {
|
||||
Map data = {
|
||||
'TYPE': type,
|
||||
'FOREIGN_KEY': widget.info['EQUIPMENT_PIPELINE_ID'],
|
||||
'FOREIGN_KEY': FOREIGN_KEY,
|
||||
};
|
||||
final raw = await ApiService.addNormalImgFiles(imagePath, data);
|
||||
final raw = await ApiService.addNFCImgFiles(imagePath, data);
|
||||
if (raw['result'] == 'success') {
|
||||
Map pd = raw['pd'];
|
||||
return pd['FILEPATH'] ?? "";
|
||||
|
|
@ -189,7 +227,7 @@ class _HomeNfcCheckDangerPageState extends State<HomeNfcCheckDangerPage> {
|
|||
void _pushDangerDetail() async {
|
||||
// pushPage 可能返回 pd,保持原逻辑:等待详情页返回新的 pd
|
||||
final result = await pushPage<Map<String, dynamic>>(
|
||||
NfcCheckDangerDetail(info: pd),
|
||||
NfcCheckDangerDetail(info: pd, unchecked: unchecked, isEdit: widget.isEdit),
|
||||
context,
|
||||
);
|
||||
if (result != null && result.isNotEmpty) {
|
||||
|
|
@ -294,11 +332,17 @@ class _HomeNfcCheckDangerPageState extends State<HomeNfcCheckDangerPage> {
|
|||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (value != "option2") {
|
||||
selectType = option;
|
||||
} else {
|
||||
// 选择“不合格”需要进入详情页面
|
||||
_pushDangerDetail();
|
||||
if (widget.isEdit) {
|
||||
if (value != "option2") {
|
||||
selectType = option;
|
||||
} else {
|
||||
// 选择“不合格”需要进入详情页面
|
||||
_pushDangerDetail();
|
||||
}
|
||||
}else { // 查看状态只有不合格可点击
|
||||
if (value == "option2") {
|
||||
_pushDangerDetail();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
@ -310,24 +354,31 @@ class _HomeNfcCheckDangerPageState extends State<HomeNfcCheckDangerPage> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 30,
|
||||
width: 90,
|
||||
IntrinsicWidth(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: isSelected ? color : Colors.grey, size: 30),
|
||||
const SizedBox(width: 8),
|
||||
Icon(icon, color: isSelected ? color : Colors.grey, size: 25),
|
||||
const SizedBox(width: 2),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected ? color : Colors.grey[600],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected ? color : Colors.grey[600],
|
||||
),
|
||||
),
|
||||
if (label == '不合格' && !unchecked)
|
||||
Column(
|
||||
children: [Icon(Icons.edit_note, color: Colors.red, size: 20,), const SizedBox(height: 10,)],
|
||||
)
|
||||
|
||||
],
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -388,11 +439,6 @@ class _HomeNfcCheckDangerPageState extends State<HomeNfcCheckDangerPage> {
|
|||
screenWidth: screenWidth,
|
||||
item: item,
|
||||
onImageTap: () {
|
||||
if (item["REFERENCE_BASIS"] == "option1") {
|
||||
// _getAlreadyUpImages(item);
|
||||
} else if (item["REFERENCE_BASIS"] == "option2") {
|
||||
// _goUnqualifiedPage(item);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
|
|
@ -415,6 +461,7 @@ class _HomeNfcCheckDangerPageState extends State<HomeNfcCheckDangerPage> {
|
|||
children: [
|
||||
_pendingTopCard(widget.info),
|
||||
const Spacer(),
|
||||
if (widget.isEdit)
|
||||
CustomButton(
|
||||
enabled: canSubmit,
|
||||
text: '提交',
|
||||
|
|
|
|||
|
|
@ -14,9 +14,14 @@ import 'package:qhd_prevention/services/nfc_service.dart';
|
|||
import 'package:qhd_prevention/tools/tools.dart';
|
||||
|
||||
class HomeNfcDetailPage extends StatefulWidget {
|
||||
const HomeNfcDetailPage({super.key, required this.info});
|
||||
const HomeNfcDetailPage({
|
||||
super.key,
|
||||
required this.info,
|
||||
required this.isEdit,
|
||||
});
|
||||
|
||||
final Map info;
|
||||
final bool isEdit;
|
||||
|
||||
@override
|
||||
State<HomeNfcDetailPage> createState() => _HomeNfcDetailPageState();
|
||||
|
|
@ -139,9 +144,8 @@ class _HomeNfcDetailPageState extends State<HomeNfcDetailPage> {
|
|||
_getNFCForUid(uid, parsedText);
|
||||
},
|
||||
onError: (err) {
|
||||
|
||||
ToastUtil.showError(context, '$err');
|
||||
LoadingDialogHelper.hide();
|
||||
ToastUtil.showError(context, '$err');
|
||||
LoadingDialogHelper.hide();
|
||||
},
|
||||
timeout: Duration(seconds: 12),
|
||||
);
|
||||
|
|
@ -159,20 +163,19 @@ class _HomeNfcDetailPageState extends State<HomeNfcDetailPage> {
|
|||
// mapToCompactJson({'PIPELINE_AREA_ID':pd['PIPELINE_AREA_ID'],'EQUIPMENT_PIPELINE_ID':pd['EQUIPMENT_PIPELINE_ID']}),
|
||||
for (Map item in items) {
|
||||
if (parsedText.isNotEmpty && item['NFC_CODE'] == uid) {
|
||||
try{
|
||||
try {
|
||||
Map parsedData = jsonDecode(parsedText);
|
||||
if (parsedData['PIPELINE_AREA_ID'] == item['PIPELINE_AREA_ID'] &&
|
||||
parsedData['EQUIPMENT_PIPELINE_ID'] == item['EQUIPMENT_PIPELINE_ID']) {
|
||||
parsedData['EQUIPMENT_PIPELINE_ID'] ==
|
||||
item['EQUIPMENT_PIPELINE_ID']) {
|
||||
result = item;
|
||||
}
|
||||
LoadingDialogHelper.hide();
|
||||
|
||||
}catch(e){
|
||||
} catch (e) {
|
||||
LoadingDialogHelper.hide();
|
||||
|
||||
ToastUtil.showError(context, 'NFC设备数据错误');
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
if (result.isEmpty) {
|
||||
|
|
@ -183,10 +186,14 @@ class _HomeNfcDetailPageState extends State<HomeNfcDetailPage> {
|
|||
}
|
||||
LoadingDialogHelper.hide();
|
||||
|
||||
Map data = {...result, ...widget.info, "NFC_CODE": uid, 'MANUAL_CONFIRMATION': '0',
|
||||
Map data = {
|
||||
...result,
|
||||
...widget.info,
|
||||
"NFC_CODE": uid,
|
||||
'MANUAL_CONFIRMATION': '0',
|
||||
};
|
||||
await pushPage(
|
||||
HomeNfcCheckDangerPage(info: data, facebookImages: [], isNfcError: false,),
|
||||
HomeNfcCheckDangerPage(info: data, facebookImages: [], isNfcError: false, isEdit: widget.isEdit,),
|
||||
context,
|
||||
);
|
||||
_getTaskDetail(refresh: true);
|
||||
|
|
@ -296,6 +303,7 @@ class _HomeNfcDetailPageState extends State<HomeNfcDetailPage> {
|
|||
text: item['USER_NAME'] ?? '',
|
||||
),
|
||||
ItemListWidget.singleLineTitleText(
|
||||
// maxLines: 1,
|
||||
label: '涉及管道区域:',
|
||||
isEditable: false,
|
||||
text: item['PIPELINE_AREAS_NAMES'] ?? '',
|
||||
|
|
@ -319,7 +327,7 @@ class _HomeNfcDetailPageState extends State<HomeNfcDetailPage> {
|
|||
final bool unchecked = item['INSPECTED_FLAG'] == '0';
|
||||
|
||||
return Container(
|
||||
height: 100,
|
||||
height: widget.isEdit ? 100 : 80,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
|
|
@ -356,14 +364,25 @@ class _HomeNfcDetailPageState extends State<HomeNfcDetailPage> {
|
|||
// 中间详情
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: (){
|
||||
onTap: () async {
|
||||
if (!unchecked) {
|
||||
Map data = {...item, ...widget.info, "NFC_CODE": item['PIPELINE_AREA_ID'], 'MANUAL_CONFIRMATION': '0',
|
||||
Map data = {
|
||||
...item,
|
||||
...widget.info,
|
||||
"NFC_CODE": item['PIPELINE_AREA_ID'],
|
||||
'MANUAL_CONFIRMATION': '0',
|
||||
};
|
||||
pushPage(
|
||||
HomeNfcCheckDangerPage(info: data, facebookImages: [], isNfcError: false,),
|
||||
context,
|
||||
await pushPage(
|
||||
HomeNfcCheckDangerPage(
|
||||
info: data,
|
||||
facebookImages: [],
|
||||
isNfcError: false,
|
||||
isEdit: widget.isEdit,
|
||||
),
|
||||
context,
|
||||
);
|
||||
_getTaskDetail(refresh: true);
|
||||
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
|
|
@ -382,71 +401,74 @@ class _HomeNfcDetailPageState extends State<HomeNfcDetailPage> {
|
|||
Text('NFC编码:${item['NFC_CODE'] ?? ''}'),
|
||||
const SizedBox(height: 6),
|
||||
unchecked
|
||||
? Row(
|
||||
spacing: 10,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => _startCheckItem(item, idx),
|
||||
child: Container(
|
||||
height: 35,
|
||||
// width: 120,
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFFFFA726),
|
||||
Color(0xFFFF7043),
|
||||
],
|
||||
? widget.isEdit
|
||||
? Row(
|
||||
spacing: 10,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => _startCheckItem(item, idx),
|
||||
child: Container(
|
||||
height: 35,
|
||||
// width: 120,
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFFFFA726),
|
||||
Color(0xFFFF7043),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: const Text(
|
||||
'NFC检查',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: const Text(
|
||||
'NFC检查',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
CustomButton(
|
||||
onPressed: () {
|
||||
pushPage(
|
||||
NfcQuestionFecebook(
|
||||
info: item,
|
||||
taskInfo: widget.info,
|
||||
),
|
||||
context,
|
||||
);
|
||||
},
|
||||
text: '手动检查',
|
||||
height: 35,
|
||||
textStyle: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
),
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox()
|
||||
: Text(
|
||||
'检查时间:${item['PATROL_TIME'] ?? ''}',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
CustomButton(
|
||||
onPressed: () {
|
||||
pushPage(
|
||||
NfcQuestionFecebook(
|
||||
info: item,
|
||||
taskInfo: widget.info,
|
||||
),
|
||||
context,
|
||||
);
|
||||
},
|
||||
text: '手动检查',
|
||||
height: 35,
|
||||
textStyle: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
),
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
],
|
||||
)
|
||||
: Text(
|
||||
'检查时间:${item['PATROL_TIME'] ?? ''}',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
|
||||
// 右侧箭头
|
||||
const SizedBox(width: 8),
|
||||
if (!unchecked)
|
||||
const Icon(Icons.chevron_right, color: Colors.grey),
|
||||
if (!unchecked) const Icon(Icons.chevron_right, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class _HomeNfcListPageState extends State<HomeNfcListPage>
|
|||
|
||||
// 分页状态 - 待巡检
|
||||
int _pendingPage = 1;
|
||||
final int _pageSize = 10;
|
||||
final int _pageSize = 20;
|
||||
bool _pendingLoading = false;
|
||||
bool _pendingHasMore = true;
|
||||
final ScrollController _pendingScrollController = ScrollController();
|
||||
|
|
@ -143,7 +143,6 @@ class _HomeNfcListPageState extends State<HomeNfcListPage>
|
|||
await _getTaskDetailList(page: _recordPage + 1, replace: false);
|
||||
}
|
||||
|
||||
// ---------- 网络请求(适配后端返回结构) ----------
|
||||
//NFC任务列表
|
||||
Future<void> _getTaskList({required int page, required bool replace}) async {
|
||||
setState(() {
|
||||
|
|
@ -207,59 +206,39 @@ class _HomeNfcListPageState extends State<HomeNfcListPage>
|
|||
_recordLoading = true;
|
||||
});
|
||||
try {
|
||||
// final res = await ApiService.nfcTaskDetailList(_pageSize, page);
|
||||
List<dynamic>? list;
|
||||
// if (res == null) {
|
||||
// list = [];
|
||||
// } else if (res is List) {
|
||||
// list = res;
|
||||
// } else if (res is Map) {
|
||||
// list = (res['data'] ?? res['list'] ?? res['items'] ?? []) as List<dynamic>?;
|
||||
// }
|
||||
// list ??= [];
|
||||
//
|
||||
// final parsed = list.map<Map<String, dynamic>>((e) {
|
||||
// if (e is Map) {
|
||||
// return {
|
||||
// 'title': (e['title'] ?? e['TASK_NAME'] ?? e['taskName'] ?? e['name'] ?? '未命名').toString(),
|
||||
// 'status': (e['status'] ?? e['TASK_STATUS'] ?? '已巡检').toString(),
|
||||
// 'department': (e['department'] ?? e['DEPARTMENT'] ?? e['dept'] ?? '').toString(),
|
||||
// 'owner': (e['owner'] ?? e['OWNER'] ?? e['person'] ?? '').toString(),
|
||||
// 'unType': (e['unType'] ?? e['UN_TYPE'] ?? e['un_type'] ?? '').toString(),
|
||||
// 'cycle': (e['cycle'] ?? e['CYCLE'] ?? '').toString(),
|
||||
// 'points': (e['points'] ?? e['POINTS'] ?? '').toString(),
|
||||
// };
|
||||
// } else {
|
||||
// final s = e?.toString() ?? '';
|
||||
// return {
|
||||
// 'title': s,
|
||||
// 'status': '已巡检',
|
||||
// 'department': '',
|
||||
// 'owner': '',
|
||||
// 'unType': '',
|
||||
// 'cycle': '',
|
||||
// 'points': '',
|
||||
// };
|
||||
// }
|
||||
// }).toList();
|
||||
//
|
||||
// final bool gotLessThanPage = parsed.length < _pageSize;
|
||||
//
|
||||
// if (replace) {
|
||||
// setState(() {
|
||||
// _recordList.clear();
|
||||
// _recordList.addAll(parsed);
|
||||
// _recordPage = 1;
|
||||
// _recordHasMore = !gotLessThanPage;
|
||||
// });
|
||||
// } else {
|
||||
// setState(() {
|
||||
// _recordList.addAll(parsed);
|
||||
// _recordPage = page;
|
||||
// if (parsed.isEmpty) _recordHasMore = false;
|
||||
// else if (parsed.length < _pageSize) _recordHasMore = false;
|
||||
// });
|
||||
// }
|
||||
final res = await ApiService.nfcInspectionRecord(_pageSize, page);
|
||||
// // 适配后端返回:尝试找到数组数据
|
||||
// List<dynamic>? list;
|
||||
if (res['result'] == 'success') {
|
||||
List<dynamic> list = res['varList'];
|
||||
|
||||
final parsed =
|
||||
list.map<Map<String, dynamic>>((e) {
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
//
|
||||
// 判断是否还有更多:如果返回数量 < pageSize 则没有更多
|
||||
final bool gotLessThanPage = parsed.length < _pageSize;
|
||||
|
||||
if (replace) {
|
||||
setState(() {
|
||||
_recordList.clear();
|
||||
_recordList.addAll(parsed);
|
||||
_recordPage = 1;
|
||||
_recordHasMore = !gotLessThanPage;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_recordList.addAll(parsed);
|
||||
_recordPage = page;
|
||||
if (parsed.isEmpty)
|
||||
_recordHasMore = false;
|
||||
else if (parsed.length < _pageSize)
|
||||
_recordHasMore = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
|
|
@ -363,7 +342,7 @@ class _HomeNfcListPageState extends State<HomeNfcListPage>
|
|||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
pushPage(HomeNfcDetailPage(info: item), context);
|
||||
pushPage(HomeNfcDetailPage(info: item, isEdit: true,), context);
|
||||
},
|
||||
child: _pendingCard(item, false),
|
||||
),
|
||||
|
|
@ -403,9 +382,14 @@ class _HomeNfcListPageState extends State<HomeNfcListPage>
|
|||
}
|
||||
}
|
||||
final item = _recordList[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: _pendingCard(item, true),
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
pushPage(HomeNfcDetailPage(info: item, isEdit: false,), context);
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
|
||||
child: _pendingCard(item, true),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -414,86 +398,88 @@ class _HomeNfcListPageState extends State<HomeNfcListPage>
|
|||
|
||||
/// 构建待巡检卡片
|
||||
Widget _pendingCard(Map<String, dynamic> item, bool isFinish) {
|
||||
return SizedBox(
|
||||
height: 180,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// 背景卡片阴影 & 图片
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Image.asset(
|
||||
'assets/images/xj_top.png',
|
||||
height: 70,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
String finishState = '';
|
||||
Color textColor = Colors.blue;
|
||||
if (isFinish) {
|
||||
if (item['PATROL_STATUS'] == '0') {
|
||||
finishState = '已巡检';
|
||||
textColor = Colors.blue;
|
||||
} else if (item['PATROL_STATUS'] == '1') {
|
||||
finishState = '超期未巡检';
|
||||
textColor = Colors.red;
|
||||
} else if (item['PATROL_STATUS'] == '2') {
|
||||
finishState = '巡检中';
|
||||
textColor = Colors.deepOrange;
|
||||
}
|
||||
} else {
|
||||
if (FormUtils.hasValue(item, 'INSPECTED_POINTS')) {
|
||||
final inspected = (item['INSPECTED_POINTS'] is int) ? item['INSPECTED_POINTS'] as int : int.tryParse('${item['INSPECTED_POINTS']}') ?? 0;
|
||||
finishState = inspected > 0 ? '巡检中' : '待巡检';
|
||||
textColor = inspected > 0 ? Colors.deepOrange : Colors.blue;
|
||||
}
|
||||
}
|
||||
|
||||
// 标题 & 状态标签
|
||||
Positioned(
|
||||
top: 12,
|
||||
left: 12,
|
||||
child: Text(
|
||||
item['TASK_NAME'] ?? '',
|
||||
style: const TextStyle(
|
||||
color: Colors.black87,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 0, horizontal: 0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 用 Stack 只包含图片 + 标题/状态
|
||||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Image.asset(
|
||||
'assets/images/xj_top.png',
|
||||
height: 70,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!isFinish)
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: Container(
|
||||
height: 30,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
Positioned(
|
||||
top: 12,
|
||||
left: 12,
|
||||
child: Text(
|
||||
item['TASK_NAME'] ?? '',
|
||||
style: const TextStyle(
|
||||
color: Colors.black87,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
item['INSPECTED_POINTS'] > 0 ? '巡检中' : '待巡检',
|
||||
style: TextStyle(
|
||||
color:
|
||||
item['INSPECTED_POINTS'] as int > 0
|
||||
? Colors.blue
|
||||
: Colors.deepOrange,
|
||||
fontSize: 14,
|
||||
),
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: Container(
|
||||
height: 30,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
finishState,
|
||||
style: TextStyle(color: textColor, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 白色信息区域(盖住图片部分)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 50, // 盖住图片底部
|
||||
// 使用 Transform 提上白色容器(视觉上覆盖图片底部)
|
||||
Transform.translate(
|
||||
offset: const Offset(0, -20),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 0),
|
||||
padding: const EdgeInsets.all(16),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(10),
|
||||
topRight: Radius.circular(10),
|
||||
bottomLeft: Radius.circular(10),
|
||||
bottomRight: Radius.circular(10),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 1))
|
||||
],
|
||||
),
|
||||
child: _buildInfoGrid(item, isFinish),
|
||||
|
|
@ -504,37 +490,57 @@ class _HomeNfcListPageState extends State<HomeNfcListPage>
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
/// 构建信息网格
|
||||
Widget _buildInfoGrid(Map<String, dynamic> item, bool isFinish) {
|
||||
return Row(
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('负责部门:${item['DEPARTMENT_NAME'] ?? ''}'),
|
||||
const SizedBox(height: 8),
|
||||
Text('巡检类型:${item['PATROL_TYPE_NAME'] ?? ''}'),
|
||||
const SizedBox(height: 8),
|
||||
Text('已巡点位:${item['INSPECTED_POINTS'] ?? '0'}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text('负责人:${item['USER_NAME'] ?? ''}'),
|
||||
const SizedBox(height: 8),
|
||||
Text('巡检周期:${item['PATROL_PERIOD_NAME'] ?? ''}'),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('负责部门:${item['DEPARTMENT_NAME'] ?? ''}'),
|
||||
const SizedBox(height: 8),
|
||||
Text('巡检类型:${item['PATROL_TYPE_NAME'] ?? ''}'),
|
||||
const SizedBox(height: 8),
|
||||
isFinish ? Text('巡检次数:${item['ACTUAL_PATROL_TIMES'] ?? '0'}') :
|
||||
Text('已巡点位:${item['INSPECTED_POINTS'] ?? '0'}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text('负责人:${item['USER_NAME'] ?? ''}'),
|
||||
const SizedBox(height: 8),
|
||||
Text('巡检周期:${item['PATROL_PERIOD_NAME'] ?? ''}'),
|
||||
|
||||
isFinish
|
||||
? Text('涉及管道区域:${item['department'] ?? ''}')
|
||||
: const SizedBox(height: 25),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isFinish)
|
||||
const SizedBox(height: 10,),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: isFinish
|
||||
? Text(
|
||||
'涉及管道区域:${item['PIPELINE_AREAS_NAMES'] ?? ''}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: const SizedBox(height: 0),)
|
||||
],
|
||||
)
|
||||
],
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import 'package:qhd_prevention/customWidget/department_person_picker.dart';
|
|||
import 'package:qhd_prevention/customWidget/department_picker.dart';
|
||||
import 'package:qhd_prevention/customWidget/department_picker_hidden_type.dart';
|
||||
import 'package:qhd_prevention/customWidget/department_picker_two.dart';
|
||||
import 'package:qhd_prevention/customWidget/full_screen_video_page.dart';
|
||||
import 'package:qhd_prevention/customWidget/single_image_viewer.dart';
|
||||
import 'package:qhd_prevention/customWidget/toast_util.dart';
|
||||
import 'package:qhd_prevention/pages/home/tap/item_list_widget.dart';
|
||||
import 'package:qhd_prevention/pages/my_appbar.dart';
|
||||
|
|
@ -19,10 +21,23 @@ import 'package:qhd_prevention/tools/tools.dart';
|
|||
import '../../../customWidget/photo_picker_row.dart';
|
||||
import '../../../http/ApiService.dart';
|
||||
|
||||
class NfcCheckDangerDetail extends StatefulWidget {
|
||||
const NfcCheckDangerDetail({super.key, required this.info});
|
||||
class nfcImgData {
|
||||
final String path;
|
||||
final String id;
|
||||
|
||||
const nfcImgData({
|
||||
required this.path,
|
||||
required this.id,
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
class NfcCheckDangerDetail extends StatefulWidget {
|
||||
const NfcCheckDangerDetail({super.key, required this.info, required this.unchecked, required this.isEdit});
|
||||
final bool unchecked;
|
||||
final Map<String, dynamic> info;
|
||||
final bool isEdit;
|
||||
|
||||
@override
|
||||
State<NfcCheckDangerDetail> createState() => _NfcCheckDangerDetailState();
|
||||
|
|
@ -40,11 +55,11 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
Map<String, dynamic> _personCache = {};
|
||||
|
||||
// 隐患图片
|
||||
late List<String> imgList = [];
|
||||
late List<String> _videos = [];
|
||||
late List<nfcImgData> imgList = [];
|
||||
late List<nfcImgData> _videos = [];
|
||||
|
||||
// 整改图片
|
||||
late List<String> zgImgList = [];
|
||||
late List<nfcImgData> zgImgList = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -55,7 +70,7 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
imgList = pd['imgList'] ?? [];
|
||||
_videos = pd['videoList'] ?? [];
|
||||
zgImgList = pd['gzImageList'] ?? [];
|
||||
|
||||
_isDanger = pd['RECTIFICATIONTYPE'] == '1';
|
||||
}
|
||||
_getHazardLevel();
|
||||
}
|
||||
|
|
@ -91,7 +106,22 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
print('Error fetching data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
String _getIDForPath(String path, List<nfcImgData> imgList) {
|
||||
for (nfcImgData data in imgList) {
|
||||
if (data.path == path) {
|
||||
return data.id;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
nfcImgData _getImageDataForPath(String path, List<nfcImgData> list) {
|
||||
for (nfcImgData data in list) {
|
||||
if (data.path == path) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return nfcImgData(path: '', id: '');
|
||||
}
|
||||
Future<void> _pickHazardType() async {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
|
|
@ -234,14 +264,29 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
RepairedPhotoSection(
|
||||
title: "隐患照片",
|
||||
maxCount: 4,
|
||||
initialMediaPaths: imgList,
|
||||
initialMediaPaths: imgList.map((item) => item.path).toList(),
|
||||
mediaType: MediaType.image,
|
||||
isEdit: widget.isEdit,
|
||||
isShowAI: true,
|
||||
onMediaAdded: (localPath) {
|
||||
imgList.add(localPath);
|
||||
imgList.add(nfcImgData(path: localPath, id: ''));
|
||||
},
|
||||
onMediaRemoved: (localPath) {
|
||||
imgList.remove(localPath);
|
||||
onMediaRemoved: (localPath) async {
|
||||
if (localPath.startsWith('http')) {
|
||||
final result = await _removeFileWithType('img', _getIDForPath(localPath, imgList));
|
||||
if (result) {
|
||||
setState(() {
|
||||
imgList.remove(_getImageDataForPath(localPath, imgList));
|
||||
});
|
||||
}
|
||||
}else{
|
||||
setState(() {
|
||||
imgList.remove(_getImageDataForPath(localPath, imgList));
|
||||
});
|
||||
}
|
||||
},
|
||||
onMediaTapped: (path) {
|
||||
presentOpaque(SingleImageViewer(imageUrl: path), context);
|
||||
},
|
||||
onChanged: (v) {},
|
||||
onAiIdentify: () {
|
||||
|
|
@ -254,23 +299,41 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
ToastUtil.showNormal(context, "识别暂时只能上传一张图片");
|
||||
return;
|
||||
}
|
||||
_identifyImg(imgList[0]);
|
||||
nfcImgData data = imgList.first;
|
||||
_identifyImg(data.path);
|
||||
},
|
||||
),
|
||||
RepairedPhotoSection(
|
||||
title: "隐患视频",
|
||||
maxCount: 1,
|
||||
isEdit: widget.isEdit,
|
||||
initialMediaPaths: _videos.map((item) => item.path).toList(),
|
||||
mediaType: MediaType.video,
|
||||
onChanged: (v) {},
|
||||
onMediaRemoved: (localPath) {
|
||||
_videos = [localPath];
|
||||
onMediaRemoved: (localPath) async {
|
||||
if (localPath.startsWith('http')) {
|
||||
final result = await _removeFileWithType('img', _getIDForPath(localPath, _videos));
|
||||
if (result) {
|
||||
setState(() {
|
||||
_videos.remove(_getImageDataForPath(localPath, _videos));
|
||||
});
|
||||
}
|
||||
}else{
|
||||
setState(() {
|
||||
_videos.remove(_getImageDataForPath(localPath, _videos));
|
||||
});
|
||||
}
|
||||
},
|
||||
onMediaTapped: (path) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black54,
|
||||
builder: (_) => VideoPlayerPopup(videoUrl: path),
|
||||
); },
|
||||
onMediaAdded: (localPath) {
|
||||
_videos = [];
|
||||
},
|
||||
onAiIdentify: () {
|
||||
// AI 视频识别逻辑
|
||||
_videos.add(nfcImgData(path: localPath, id: ''));
|
||||
},
|
||||
onAiIdentify: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -278,7 +341,7 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
|
||||
ItemListWidget.multiLineTitleTextField(
|
||||
label: '隐患描述',
|
||||
isEditable: true,
|
||||
isEditable: widget.isEdit,
|
||||
isRequired: false,
|
||||
text: pd['HIDDENDESCR'] ?? '',
|
||||
hintText: '请对隐患进行详细描述(必填项)',
|
||||
|
|
@ -292,7 +355,7 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
|
||||
ItemListWidget.multiLineTitleTextField(
|
||||
label: '隐患部位',
|
||||
isEditable: true,
|
||||
isEditable: widget.isEdit,
|
||||
isRequired: false,
|
||||
// controller: TextEditingController(text: pd['HIDDENPART'] ?? ''),
|
||||
text: pd['HIDDENPART'] ?? '',
|
||||
|
|
@ -308,14 +371,14 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
ItemListWidget.selectableLineTitleTextRightButton(
|
||||
label: '隐患级别',
|
||||
isRequired: false,
|
||||
isEditable: true,
|
||||
isEditable: widget.isEdit,
|
||||
text: pd['HIDDENLEVELNAME'] ?? '',
|
||||
onTap: chooseDangerLevel,
|
||||
),
|
||||
const Divider(),
|
||||
ItemListWidget.selectableLineTitleTextRightButton(
|
||||
label: '隐患类型',
|
||||
isEditable: true,
|
||||
isEditable: widget.isEdit,
|
||||
isRequired: false,
|
||||
text: pd['HIDDENTYPE_NAME'] ?? '',
|
||||
onTap: _pickHazardType,
|
||||
|
|
@ -326,6 +389,8 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
title: "是否立即整改",
|
||||
horizontalPadding: 0,
|
||||
verticalPadding: 0,
|
||||
text: _isDanger ? '立即整改' : '限期整改',
|
||||
isEdit: widget.isEdit,
|
||||
yesLabel: "是",
|
||||
noLabel: "否",
|
||||
groupValue: _isDanger,
|
||||
|
|
@ -342,7 +407,7 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
children: [
|
||||
ItemListWidget.multiLineTitleTextField(
|
||||
label: '整改描述',
|
||||
isEditable: true,
|
||||
isEditable: widget.isEdit,
|
||||
isRequired: false,
|
||||
text: pd['RECTIFYDESCR'] ?? '',
|
||||
hintText: '请对隐患进行详细描述(必填项)',
|
||||
|
|
@ -358,15 +423,29 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
horizontalPadding: 12,
|
||||
title: "整改后图片",
|
||||
maxCount: 4,
|
||||
initialMediaPaths: zgImgList,
|
||||
isEdit: widget.isEdit,
|
||||
initialMediaPaths: zgImgList.map((item) => item.path).toList(),
|
||||
mediaType: MediaType.image,
|
||||
isShowAI: false,
|
||||
|
||||
onMediaAdded: (localPath) {
|
||||
zgImgList.add(localPath);
|
||||
onMediaTapped: (path) {
|
||||
presentOpaque(SingleImageViewer(imageUrl: path), context);
|
||||
},
|
||||
onMediaRemoved: (localPath) {
|
||||
zgImgList.remove(localPath);
|
||||
onMediaAdded: (localPath) {
|
||||
zgImgList.add(nfcImgData(path: localPath, id: ''));
|
||||
},
|
||||
onMediaRemoved: (localPath) async {
|
||||
if (localPath.startsWith('http')) {
|
||||
final result = await _removeFileWithType('img', _getIDForPath(localPath, zgImgList));
|
||||
if (result) {
|
||||
setState(() {
|
||||
zgImgList.remove(_getImageDataForPath(localPath, zgImgList));
|
||||
});
|
||||
}
|
||||
}else{
|
||||
setState(() {
|
||||
zgImgList.remove(_getImageDataForPath(localPath, zgImgList));
|
||||
});
|
||||
}
|
||||
},
|
||||
onChanged: (v) {},
|
||||
onAiIdentify: () {},
|
||||
|
|
@ -378,7 +457,7 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
children: [
|
||||
ItemListWidget.selectableLineTitleTextRightButton(
|
||||
label: '整改责任部门',
|
||||
isEditable: true,
|
||||
isEditable: widget.isEdit,
|
||||
isRequired: false,
|
||||
text: pd['RECTIFICATIONDEPTNAME'] ?? '',
|
||||
onTap: () {
|
||||
|
|
@ -388,7 +467,7 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
const Divider(),
|
||||
ItemListWidget.selectableLineTitleTextRightButton(
|
||||
label: '整改责任人',
|
||||
isEditable: true,
|
||||
isEditable: widget.isEdit,
|
||||
isRequired: false,
|
||||
text: pd['RECTIFICATIONORNAME'] ?? '',
|
||||
onTap: () {
|
||||
|
|
@ -398,7 +477,7 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
const Divider(),
|
||||
ItemListWidget.selectableLineTitleTextRightButton(
|
||||
label: '整改期限',
|
||||
isEditable: true,
|
||||
isEditable: widget.isEdit,
|
||||
isRequired: false,
|
||||
text: pd['RECTIFICATIONDEADLINE'] ?? '',
|
||||
onTap: () {
|
||||
|
|
@ -427,9 +506,13 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
SizedBox(height: 30),
|
||||
CustomButton(
|
||||
onPressed: () {
|
||||
_riskListCheckAppAdd();
|
||||
if (widget.isEdit) {
|
||||
_riskListCheckAppAdd();
|
||||
}else{
|
||||
Navigator.pop(context, pd);
|
||||
}
|
||||
},
|
||||
text: "确定",
|
||||
text: widget.isEdit ? "确定" : "返回",
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
SizedBox(height: 30),
|
||||
|
|
@ -439,7 +522,19 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _removeFileWithType(String type, String id) async{
|
||||
if (id.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
Map data = {'IMGFILES_ID': id};
|
||||
final result = await ApiService.deleteNFCImage(data);
|
||||
if (result['result'] == 'success') {
|
||||
return true;
|
||||
}else{
|
||||
return false;
|
||||
}
|
||||
// return
|
||||
}
|
||||
Future<void> _riskListCheckAppAdd() async {
|
||||
if (imgList.isEmpty) {
|
||||
ToastUtil.showNormal(context, '请上传隐患图片');
|
||||
|
|
@ -463,7 +558,7 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
ToastUtil.showNormal(context, '请填整改描述');
|
||||
return;
|
||||
}
|
||||
if (zgImgList.isEmpty) {
|
||||
if (zgImgList.length == 0) {
|
||||
ToastUtil.showNormal(context, '请上传整改后图片');
|
||||
return;
|
||||
}
|
||||
|
|
@ -481,14 +576,16 @@ class _NfcCheckDangerDetailState extends State<NfcCheckDangerDetail> {
|
|||
return;
|
||||
}
|
||||
}
|
||||
List HIDDENTYPE = pd['HIDDENTYPE'] ?? [];
|
||||
pd['RECTIFICATIONTYPE'] = _isDanger ? '1' : '2';
|
||||
|
||||
final HIDDENTYPE = pd['HIDDENTYPE'];
|
||||
if (HIDDENTYPE is List) {
|
||||
pd['HIDDENTYPE1'] = HIDDENTYPE.length > 0 ? HIDDENTYPE[0] : "";
|
||||
pd['HIDDENTYPE2'] = HIDDENTYPE.length > 1 ? HIDDENTYPE[1] : "";
|
||||
pd['HIDDENTYPE3'] = HIDDENTYPE.length > 2 ? HIDDENTYPE[2] : "";
|
||||
}
|
||||
pd['CREATOR'] = SessionService.instance.loginUserId;
|
||||
pd['SOURCE'] = '6';
|
||||
pd['HIDDENTYPE1'] = HIDDENTYPE.length > 0 ? HIDDENTYPE[0] : "";
|
||||
pd['HIDDENTYPE2'] = HIDDENTYPE.length > 1 ? HIDDENTYPE[1] : "";
|
||||
pd['HIDDENTYPE3'] = HIDDENTYPE.length > 2 ? HIDDENTYPE[2] : "";
|
||||
|
||||
pd['RECTIFICATIONTYPE'] = _isDanger ? '1' : '2';
|
||||
pd['imgList'] = imgList;
|
||||
pd['videoList'] = _videos;
|
||||
pd['gzImageList'] = zgImgList;
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ class _NfcQuestionFecebookState extends State<NfcQuestionFecebook> {
|
|||
'DESCRIPTION': text,
|
||||
};
|
||||
|
||||
pushPage(HomeNfcCheckDangerPage(info: data, facebookImages: _images, isNfcError: true,), context);
|
||||
pushPage(HomeNfcCheckDangerPage(info: data, facebookImages: _images, isNfcError: true,isEdit: true,), context);
|
||||
|
||||
// String imagePaths = "";
|
||||
// for (int i = 0; i < _images.length; i++) {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,13 @@ class _StudyClassListPageState extends State<StudyClassListPage> {
|
|||
LoadingDialogHelper.hide();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _nowStudy(Map item) async {
|
||||
final result = await ApiService.getVideoPermissions();
|
||||
if (result['result'] == 'success') {
|
||||
SessionService.instance.setStudyToken(result['token'] ?? '');
|
||||
pushPage(StudyDetailPage(item, widget.studyData['STUDENT_ID']), context);
|
||||
}
|
||||
}
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
|
@ -82,7 +88,7 @@ class _StudyClassListPageState extends State<StudyClassListPage> {
|
|||
ApiService.baseImgPath + item['COVERPATH'],
|
||||
width: 100,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
|
|
@ -103,23 +109,21 @@ class _StudyClassListPageState extends State<StudyClassListPage> {
|
|||
overflow: TextOverflow.visible,
|
||||
style: const TextStyle(color: Colors.black54),
|
||||
),),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
SizedBox(),
|
||||
CustomButton(
|
||||
text: "立即学习",
|
||||
backgroundColor: Colors.blue,
|
||||
height: 38,
|
||||
onPressed: () {
|
||||
pushPage(StudyDetailPage(item, widget.studyData['STUDENT_ID']), context);
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const SizedBox(),
|
||||
CustomButton(
|
||||
text: "立即学习",
|
||||
backgroundColor: Colors.blue,
|
||||
height: 38,
|
||||
onPressed: () {
|
||||
_nowStudy(item);
|
||||
},
|
||||
),
|
||||
],)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:qhd_prevention/customWidget/remote_file_page.dart';
|
||||
import 'package:qhd_prevention/pages/home/study/take_exam_page.dart';
|
||||
import 'package:qhd_prevention/pages/home/study/study_practise_page.dart';
|
||||
import 'package:qhd_prevention/pages/my_appbar.dart';
|
||||
|
|
@ -14,15 +15,12 @@ import '../../../customWidget/video_player_widget.dart';
|
|||
import '../../../http/HttpManager.dart';
|
||||
import 'face_ecognition_page.dart';
|
||||
|
||||
enum TakeExamType {
|
||||
video_study,
|
||||
strengththen,
|
||||
list
|
||||
}
|
||||
enum TakeExamType { video_study, strengththen, list }
|
||||
|
||||
class StudyDetailPage extends StatefulWidget {
|
||||
final Map studyDetailDetail;
|
||||
final String studentId;
|
||||
|
||||
const StudyDetailPage(this.studyDetailDetail, this.studentId, {super.key});
|
||||
|
||||
@override
|
||||
|
|
@ -69,6 +67,7 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
_tabController.dispose();
|
||||
_videoController?.removeListener(_onTimeUpdate);
|
||||
_videoController?.dispose();
|
||||
_videoController = null;
|
||||
_faceTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
@ -76,15 +75,16 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
Future<void> _showFaceIntro() async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (_) => CustomAlertDialog(
|
||||
title: '温馨提示',
|
||||
content:
|
||||
'重要提醒:尊敬的用户,根据规定我们会在您学习过程中多次进行人脸识别认证,为了保护您的隐私请您在摄像设备视野内确保衣冠整齐。',
|
||||
cancelText: '取消',
|
||||
confirmText: '同意并继续',
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () => {},
|
||||
),
|
||||
builder:
|
||||
(_) => CustomAlertDialog(
|
||||
title: '温馨提示',
|
||||
content:
|
||||
'重要提醒:尊敬的用户,根据规定我们会在您学习过程中多次进行人脸识别认证,为了保护您的隐私请您在摄像设备视野内确保衣冠整齐。',
|
||||
cancelText: '取消',
|
||||
confirmText: '同意并继续',
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () => {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -144,11 +144,11 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
}
|
||||
|
||||
Future<void> _onVideoTap(
|
||||
Map<String, dynamic> data,
|
||||
bool hasNodes,
|
||||
int fi,
|
||||
int ni,
|
||||
) async {
|
||||
Map<String, dynamic> data,
|
||||
bool hasNodes,
|
||||
int fi,
|
||||
int ni,
|
||||
) async {
|
||||
// clear face timer on backend
|
||||
await ApiService.fnClearUserFaceTime();
|
||||
_faceTimer?.cancel();
|
||||
|
|
@ -169,8 +169,12 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
if ((data['IS_VIDEO'] ?? 0) == 1) {
|
||||
// document
|
||||
if (data['VIDEOFILES'] != null) {
|
||||
_videoController?.pause();
|
||||
await pushPage(
|
||||
StudyPractisePage(videoCoursewareId: data['VIDEOCOURSEWARE_ID']),
|
||||
RemoteFilePage(
|
||||
fileUrl: ApiService.baseImgPath + data['VIDEOFILES'],
|
||||
countdownSeconds: 10,
|
||||
),
|
||||
context,
|
||||
);
|
||||
await _submitPlayTime(
|
||||
|
|
@ -178,9 +182,9 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
seconds: int.parse(data['VIDEOTIME'] ?? '0'),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('课件文件资源已失效,请联系管理员')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('课件文件资源已失效,请联系管理员')));
|
||||
}
|
||||
} else {
|
||||
// video
|
||||
|
|
@ -199,9 +203,9 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
if (passed == true) {
|
||||
await onPass();
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('人脸验证未通过,无法继续')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('人脸验证未通过,无法继续')));
|
||||
}
|
||||
} else {
|
||||
await onPass();
|
||||
|
|
@ -221,14 +225,15 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
);
|
||||
|
||||
final raw = prog['pd']?['RESOURCETIME'];
|
||||
final seen = (() {
|
||||
if (raw == null) return 0;
|
||||
// 如果本身就是数字
|
||||
if (raw is num) return raw.toInt();
|
||||
// 否则转成字符串再 parse
|
||||
final s = raw.toString();
|
||||
return (double.tryParse(s) ?? 0.0).toInt();
|
||||
})();
|
||||
final seen =
|
||||
(() {
|
||||
if (raw == null) return 0;
|
||||
// 如果本身就是数字
|
||||
if (raw is num) return raw.toInt();
|
||||
// 否则转成字符串再 parse
|
||||
final s = raw.toString();
|
||||
return (double.tryParse(s) ?? 0.0).toInt();
|
||||
})();
|
||||
|
||||
// 先销毁旧 controller
|
||||
_videoController?.removeListener(_onTimeUpdate);
|
||||
|
|
@ -247,15 +252,16 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
..addListener(_onTimeUpdate);
|
||||
}
|
||||
|
||||
|
||||
void _onTimeUpdate() {
|
||||
if (_videoController == null || !_videoController!.value.isPlaying) return;
|
||||
final curr = _videoController!.value.position;
|
||||
if (!_throttleFlag && (curr - _lastReported).inSeconds >= 5) {
|
||||
_throttleFlag = true;
|
||||
_lastReported = curr;
|
||||
_submitPlayTime(end: false, seconds: curr.inSeconds)
|
||||
.whenComplete(() => _throttleFlag = false);
|
||||
_submitPlayTime(
|
||||
end: false,
|
||||
seconds: curr.inSeconds,
|
||||
).whenComplete(() => _throttleFlag = false);
|
||||
}
|
||||
final pos = _videoController!.value.position;
|
||||
final dur = _videoController!.value.duration;
|
||||
|
|
@ -273,29 +279,34 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
if (_currentVideoData == null) return;
|
||||
|
||||
try {
|
||||
final resData = (await ApiService.fnSubmitPlayTime(
|
||||
_currentVideoData!['VIDEOCOURSEWARE_ID'],
|
||||
_currentVideoData!['CURRICULUM_ID'],
|
||||
end ? '1' : '0',
|
||||
seconds,
|
||||
_currentVideoData!['CHAPTER_ID'],
|
||||
widget.studentId,
|
||||
_classCurriculumId,
|
||||
_classId,
|
||||
));
|
||||
Map data = {
|
||||
'VIDEOCOURSEWARE_ID': _currentVideoData!['VIDEOCOURSEWARE_ID'] ?? '',
|
||||
'CURRICULUM_ID': _currentVideoData!['CURRICULUM_ID'] ?? '',
|
||||
'CHAPTER_ID': _currentVideoData!['CHAPTER_ID'] ?? '',
|
||||
'RESOURCETIME': seconds,
|
||||
'IS_END': end ? '1' : '0',
|
||||
'CLASS_ID': _classId,
|
||||
'CLASSCURRICULUM_ID': _classCurriculumId,
|
||||
'STUDENT_ID': widget.studentId,
|
||||
'loading': false,
|
||||
};
|
||||
final resData = await ApiService.fnSubmitPlayTime(data);
|
||||
final pd = resData['pd'] ?? {};
|
||||
// 更新进度显示
|
||||
final comp = pd['PLAYCOUNT'] != null && pd['PLAYCOUNT'] > 0;
|
||||
final resT = pd['RESOURCETIME'] ?? seconds;
|
||||
final pct = comp
|
||||
? 100
|
||||
: (resT / (_currentVideoData!['VIDEOTIME'] ?? 1) * 100)
|
||||
.clamp(0, 100);
|
||||
final videoTimeRaw = _currentVideoData!['VIDEOTIME'];
|
||||
final videoTime =
|
||||
(videoTimeRaw is String)
|
||||
? double.tryParse(videoTimeRaw) ?? 1
|
||||
: (videoTimeRaw is num ? videoTimeRaw.toDouble() : 1);
|
||||
|
||||
final pct = comp ? 100 : (resT / videoTime * 100).clamp(0, 100);
|
||||
final str = '${pct.floor()}%';
|
||||
setState(() {
|
||||
if (_hasNodes) {
|
||||
_videoList[_currentFirstIndex]['nodes'][_currentNodeIndex]
|
||||
['percent'] = str;
|
||||
_videoList[_currentFirstIndex]['nodes'][_currentNodeIndex]['percent'] =
|
||||
str;
|
||||
} else {
|
||||
_videoList[_currentFirstIndex]['percent'] = str;
|
||||
}
|
||||
|
|
@ -304,18 +315,19 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
// 如果结束且可考试,弹框
|
||||
if (end && pd['CANEXAM'] == '1') {
|
||||
_videoController?.pause();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => CustomAlertDialog(
|
||||
title: '提示',
|
||||
content: '当前任务内所有课程均已学完,是否直接参加考试?',
|
||||
confirmText: '是',
|
||||
cancelText: '否',
|
||||
),
|
||||
) ??
|
||||
final ok =
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(_) => CustomAlertDialog(
|
||||
title: '提示',
|
||||
content: '当前任务内所有课程均已学完,是否直接参加考试?',
|
||||
confirmText: '是',
|
||||
cancelText: '否',
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
if (ok) {
|
||||
|
||||
_startExam(resData);
|
||||
} else {
|
||||
_videoController?.play();
|
||||
|
|
@ -337,12 +349,12 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
_loading = true;
|
||||
});
|
||||
final arguments = {
|
||||
'STAGEEXAMPAPERINPUT_ID': paper['STAGEEXAMPAPERINPUT_ID']??'',
|
||||
'STAGEEXAMPAPER_ID': paper['STAGEEXAMPAPER_ID']??'',
|
||||
'STAGEEXAMPAPERINPUT_ID': paper['STAGEEXAMPAPERINPUT_ID'] ?? '',
|
||||
'STAGEEXAMPAPER_ID': paper['STAGEEXAMPAPER_ID'] ?? '',
|
||||
'CLASS_ID': _classId,
|
||||
'POST_ID': pd['POST_ID'] ?? '',
|
||||
'STUDENT_ID': widget.studentId,
|
||||
'NUMBEROFEXAMS': pd['NUMBEROFEXAMS'] ?? ''
|
||||
'NUMBEROFEXAMS': pd['NUMBEROFEXAMS'] ?? '',
|
||||
};
|
||||
print('--_startExam data---$arguments');
|
||||
|
||||
|
|
@ -351,15 +363,21 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
_loading = false;
|
||||
});
|
||||
if (data['result'] == 'success') {
|
||||
pushPage(TakeExamPage(examInfo: {
|
||||
'CLASS_ID':_classId,
|
||||
'POST_ID': pd['POST_ID'] ?? '',
|
||||
'STUDENT_ID': widget.studentId,
|
||||
'STRENGTHEN_PAPER_QUESTION_ID': paper['STAGEEXAMPAPERINPUT_ID']??'',
|
||||
...data
|
||||
}, examType: TakeExamType.video_study), context);
|
||||
|
||||
}else{
|
||||
pushPage(
|
||||
TakeExamPage(
|
||||
examInfo: {
|
||||
'CLASS_ID': _classId,
|
||||
'POST_ID': pd['POST_ID'] ?? '',
|
||||
'STUDENT_ID': widget.studentId,
|
||||
'STRENGTHEN_PAPER_QUESTION_ID':
|
||||
paper['STAGEEXAMPAPERINPUT_ID'] ?? '',
|
||||
...data,
|
||||
},
|
||||
examType: TakeExamType.video_study,
|
||||
),
|
||||
context,
|
||||
);
|
||||
} else {
|
||||
ToastUtil.showError(context, '请求错误');
|
||||
}
|
||||
}
|
||||
|
|
@ -391,9 +409,27 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
void _controllerListener() {
|
||||
if (mounted) setState(() {});
|
||||
Widget _buildVideoOrCover(double containerW, double containerH) {
|
||||
final c = _videoController;
|
||||
if (c != null && c.value.isInitialized) {
|
||||
return VideoPlayerWidget(
|
||||
allowSeek: false,
|
||||
controller: _videoController,
|
||||
coverUrl:
|
||||
_videoCoverUrl.isNotEmpty
|
||||
? ApiService.baseImgPath + _videoCoverUrl
|
||||
: ApiService.baseImgPath + (_info?['COVERPATH'] ?? ''),
|
||||
aspectRatio: _videoController?.value.aspectRatio ?? 16 / 9,
|
||||
);
|
||||
} else {
|
||||
// controller 被销毁或未初始化,显示封面
|
||||
return Image.network(
|
||||
'${ApiService.baseImgPath}${_info?['COVERPATH'] ?? ''}',
|
||||
fit: BoxFit.fill,
|
||||
width: containerW,
|
||||
height: containerH,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -410,18 +446,8 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 250,
|
||||
width: screenWidth(context),
|
||||
child: VideoPlayerWidget(
|
||||
allowSeek: false,
|
||||
controller: _videoController,
|
||||
coverUrl: _videoCoverUrl.isNotEmpty
|
||||
? ApiService.baseImgPath + _videoCoverUrl
|
||||
: ApiService.baseImgPath + (info['COVERPATH'] ?? ''),
|
||||
aspectRatio: _videoController?.value.aspectRatio ?? 16/9,
|
||||
),
|
||||
),
|
||||
_buildVideoOrCover(screenWidth(context), 250),
|
||||
const SizedBox(height: 5,),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Colors.white,
|
||||
|
|
@ -467,29 +493,31 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
final item = _videoList[idx] as Map<String, dynamic>;
|
||||
final nodes = item['nodes'] as List<dynamic>?;
|
||||
if (nodes != null && nodes.isNotEmpty) {
|
||||
return ExpansionTile(
|
||||
title: Text(item['NAME'] ?? ''),
|
||||
children:
|
||||
nodes
|
||||
.asMap()
|
||||
.entries
|
||||
.map(
|
||||
(e) => _buildVideoItem(
|
||||
e.value as Map<String, dynamic>,
|
||||
true,
|
||||
idx,
|
||||
e.key,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
// 章节标题 + 直接展开的子项列表(不折叠)
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...nodes.asMap().entries.map(
|
||||
(e) => _buildVideoItem(
|
||||
item,
|
||||
e.value as Map<String, dynamic>,
|
||||
true,
|
||||
idx,
|
||||
e.key,
|
||||
),
|
||||
),
|
||||
const Divider(height: 1), // 每章节后一个分割线
|
||||
],
|
||||
);
|
||||
}
|
||||
return _buildVideoItem(item, false, idx, 0);
|
||||
// 没有子节点,直接显示该条目
|
||||
return _buildVideoItem(item, item, false, idx, 0);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoItem(
|
||||
Map<String, dynamic> item,
|
||||
Map<String, dynamic> m,
|
||||
bool hasNodes,
|
||||
int fi,
|
||||
|
|
@ -504,9 +532,10 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.file_copy_rounded, color: Colors.grey, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
m['NAME'] ?? '',
|
||||
item['NAME'] ?? '',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
|
|
@ -540,12 +569,15 @@ class _StudyDetailPageState extends State<StudyDetailPage>
|
|||
),
|
||||
if (m['IS_VIDEO'] == 0) ...[
|
||||
Text(secondsCount(m['VIDEOTIME'])),
|
||||
const SizedBox(width: 6),
|
||||
const Icon(Icons.play_circle, color: Colors.blue),
|
||||
],
|
||||
CustomButton(
|
||||
onPressed:
|
||||
() => pushPage(
|
||||
StudyPractisePage(videoCoursewareId: m['VIDEOCOURSEWARE_ID']),
|
||||
StudyPractisePage(
|
||||
videoCoursewareId: m['VIDEOCOURSEWARE_ID'],
|
||||
),
|
||||
context,
|
||||
),
|
||||
text: "课后练习",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:convert';
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:qhd_prevention/customWidget/custom_button.dart';
|
||||
import 'package:qhd_prevention/main.dart';
|
||||
import 'package:qhd_prevention/pages/home/study/strengthen_video_study_page.dart';
|
||||
import 'package:qhd_prevention/pages/home/study/study_class_list_page.dart';
|
||||
import 'package:qhd_prevention/pages/home/study/study_detail_page.dart';
|
||||
|
|
@ -21,7 +22,7 @@ class StudyMyTaskPage extends StatefulWidget {
|
|||
State<StudyMyTaskPage> createState() => _StudyMyTaskPageState();
|
||||
}
|
||||
|
||||
class _StudyMyTaskPageState extends State<StudyMyTaskPage> {
|
||||
class _StudyMyTaskPageState extends State<StudyMyTaskPage> with RouteAware {
|
||||
int _page = 1;
|
||||
final int _showCount = 10;
|
||||
bool _isLoading = false;
|
||||
|
|
@ -29,29 +30,60 @@ class _StudyMyTaskPageState extends State<StudyMyTaskPage> {
|
|||
int _totalPage = 1;
|
||||
List<dynamic> _list = [];
|
||||
Timer? _timer;
|
||||
late DateTime _now; // 用于“立即学习”的起始时间判断
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_now = DateTime.now();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _getStudyList());
|
||||
_startCountdownTimer();
|
||||
}
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final modalRoute = ModalRoute.of(context);
|
||||
if (modalRoute is PageRoute) {
|
||||
routeObserver.subscribe(this, modalRoute);
|
||||
}
|
||||
}
|
||||
|
||||
/// 当页面从覆盖中返回到前台
|
||||
@override
|
||||
void didPopNext() {
|
||||
if (!_isLoading) _getStudyList();
|
||||
}
|
||||
@override
|
||||
void didPush() {
|
||||
// 可选:首次 initState 已经调用过一次 _getStudyList,
|
||||
if (!_isLoading) _getStudyList();
|
||||
}
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
// 退订 routeObserver(防止内存泄漏)
|
||||
try {
|
||||
routeObserver.unsubscribe(this);
|
||||
} catch (_) {}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 每秒减少 remainingSeconds,用于倒计时显示
|
||||
void _startCountdownTimer() {
|
||||
if (_timer != null && _timer!.isActive) return; // 避免重复开启
|
||||
|
||||
_timer = Timer.periodic(Duration(seconds: 1), (_) {
|
||||
setState(() {
|
||||
for (var item in _list) {
|
||||
final rs = item['remainingSeconds'] as int? ?? 0;
|
||||
item['remainingSeconds'] = rs > 0 ? rs - 1 : 0;
|
||||
for (var i = 0; i < _list.length; i++) {
|
||||
final endTimeStr = _list[i]["END_TIME"] as String;
|
||||
|
||||
try {
|
||||
// 直接用原始字符串解析
|
||||
final endTime = DateTime.parse(endTimeStr);
|
||||
final now = DateTime.now();
|
||||
final seconds = endTime.difference(now).inSeconds;
|
||||
|
||||
_list[i]['remainingSeconds'] = seconds > 0 ? seconds : 0;
|
||||
} catch (e) {
|
||||
_list[i]['remainingSeconds'] = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -90,6 +122,8 @@ class _StudyMyTaskPageState extends State<StudyMyTaskPage> {
|
|||
}
|
||||
_hasMore = _page < _totalPage;
|
||||
if (_hasMore) _page++;
|
||||
_startCountdownTimer();
|
||||
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -181,7 +215,7 @@ class _StudyMyTaskPageState extends State<StudyMyTaskPage> {
|
|||
}
|
||||
|
||||
Widget _buildItem(Map item) {
|
||||
final now = _now;
|
||||
final now = DateTime.now();
|
||||
final start = DateTime.tryParse(item['START_TIME'] ?? '');
|
||||
final end = DateTime.tryParse(item['END_TIME'] ?? '');
|
||||
final nowOk =
|
||||
|
|
@ -215,6 +249,7 @@ class _StudyMyTaskPageState extends State<StudyMyTaskPage> {
|
|||
// 顶部:名称 + 状态
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
|
|
@ -222,6 +257,7 @@ class _StudyMyTaskPageState extends State<StudyMyTaskPage> {
|
|||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10,),
|
||||
Text(
|
||||
_stateText(item['STUDYSTATE']),
|
||||
style: TextStyle(color: _stateColor(item['STUDYSTATE'])),
|
||||
|
|
|
|||
|
|
@ -188,17 +188,23 @@ class _PracticePageState extends State<StudyPractisePage> {
|
|||
}) {
|
||||
Color fg = Colors.black87;
|
||||
Color bg = Colors.grey.shade200;
|
||||
Color hasTextColor = Colors.black54;
|
||||
if (right) {
|
||||
fg = Colors.green;
|
||||
bg = Colors.green;
|
||||
hasTextColor = Colors.white;
|
||||
}
|
||||
if (err) {
|
||||
fg = Colors.red;
|
||||
bg = Colors.red;
|
||||
hasTextColor = Colors.white;
|
||||
|
||||
}
|
||||
if (warning) {
|
||||
fg = Colors.green;
|
||||
bg = Colors.green;
|
||||
hasTextColor = Colors.white;
|
||||
|
||||
}
|
||||
if (active) fg = Colors.blue;
|
||||
|
||||
|
|
@ -226,7 +232,7 @@ class _PracticePageState extends State<StudyPractisePage> {
|
|||
: Text(label, style: TextStyle(color: fg)))
|
||||
: Text(
|
||||
label,
|
||||
style: TextStyle(color: multiple ? fg : Colors.white),
|
||||
style: TextStyle(color: multiple ? fg : hasTextColor),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
|
|
@ -249,6 +255,7 @@ class _PracticePageState extends State<StudyPractisePage> {
|
|||
Widget build(BuildContext context) {
|
||||
final q = options.isNotEmpty ? options[current] : null;
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: MyAppbar(title: '课后练习'),
|
||||
body:
|
||||
loading
|
||||
|
|
|
|||
|
|
@ -82,13 +82,25 @@ class _TakeExamPageState extends State<TakeExamPage> {
|
|||
.map((e) => Question.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final numberOfExams = widget.examInfo['NUMBEROFEXAMS'] as String? ?? '0';
|
||||
final numberOfExams = widget.examInfo['NUMBEROFEXAMS'];
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (numberOfExams == '-9999') {
|
||||
_showTip('强化学习考试开始,限时${info['ANSWERSHEETTIME']}分钟,请注意答题时间!');
|
||||
} else {
|
||||
if (numberOfExams is int) {
|
||||
if (numberOfExams > 0) {
|
||||
}else if (numberOfExams == -9999) {
|
||||
_showTip('强化学习考试开始,限时${info['ANSWERSHEETTIME']}分钟,请注意答题时间!');
|
||||
|
||||
}else{
|
||||
_showTip('您无考试次数!');
|
||||
}
|
||||
|
||||
}else if (numberOfExams is String) {
|
||||
if (numberOfExams == '-9999') {
|
||||
_showTip('强化学习考试开始,限时${info['ANSWERSHEETTIME']}分钟,请注意答题时间!');
|
||||
}
|
||||
}else {
|
||||
_showTip('您无考试次数!');
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
final minutes = info['ANSWERSHEETTIME'] as int? ?? 0;
|
||||
|
|
@ -295,8 +307,8 @@ class _TakeExamPageState extends State<TakeExamPage> {
|
|||
final q = questions.isNotEmpty ? questions[current] : null;
|
||||
return PopScope(
|
||||
canPop: false, // 禁用返回
|
||||
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: const MyAppbar(title: '课程考试', isBack: false,),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class ItemListWidget {
|
|||
bool strongRequired = false,
|
||||
ValueChanged<String>? onChanged,
|
||||
ValueChanged<String>? onFieldSubmitted,
|
||||
int maxLines = 5,
|
||||
|
||||
/// 强制必选 不受是否可以编译和是否必选影响
|
||||
TextInputType keyboardType = TextInputType.text,
|
||||
|
|
@ -71,7 +72,7 @@ class ItemListWidget {
|
|||
)
|
||||
: Expanded(child: Text(
|
||||
text ?? '',
|
||||
maxLines: 5,
|
||||
maxLines: maxLines,
|
||||
style: TextStyle(fontSize: fontSize, color: detailtextColor),
|
||||
textAlign: TextAlign.right,
|
||||
overflow: TextOverflow.ellipsis, // 超出省略
|
||||
|
|
@ -122,7 +123,7 @@ class ItemListWidget {
|
|||
isEditable
|
||||
? TextFormField(
|
||||
autofocus: false,
|
||||
initialValue: text,
|
||||
initialValue: controller == null ? text : null,
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.multiline,
|
||||
maxLines: null,
|
||||
|
|
@ -132,7 +133,6 @@ class ItemListWidget {
|
|||
textAlignVertical: TextAlignVertical.top,
|
||||
style: TextStyle(fontSize: fontSize),
|
||||
decoration: InputDecoration(
|
||||
|
||||
hintText: hintText,
|
||||
// 去掉 TextField 默认内边距
|
||||
contentPadding: EdgeInsets.zero,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:qhd_prevention/customWidget/custom_button.dart';
|
||||
import 'package:qhd_prevention/customWidget/picker/CupertinoDatePicker.dart';
|
||||
import 'package:qhd_prevention/customWidget/single_image_viewer.dart';
|
||||
import 'package:qhd_prevention/customWidget/toast_util.dart';
|
||||
import 'package:qhd_prevention/customWidget/date_picker_dialog.dart';
|
||||
|
|
@ -20,8 +22,8 @@ class MeasuresListWidget extends StatelessWidget {
|
|||
required this.isAllowEdit,
|
||||
this.onSign,
|
||||
this.isShowSign = true,
|
||||
|
||||
});
|
||||
|
||||
/// 接口返回的原始 Map 列表
|
||||
final List<Map<String, dynamic>> measuresList;
|
||||
|
||||
|
|
@ -37,7 +39,6 @@ class MeasuresListWidget extends StatelessWidget {
|
|||
/// 操作那里是否显示签名图片
|
||||
final bool isShowSign;
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (measuresList.isEmpty) {
|
||||
|
|
@ -57,8 +58,9 @@ class MeasuresListWidget extends StatelessWidget {
|
|||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Table(
|
||||
defaultVerticalAlignment: TableCellVerticalAlignment.middle, // 或 .top / .bottom
|
||||
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||||
|
||||
// 或 .top / .bottom
|
||||
columnWidths: const {
|
||||
0: FlexColumnWidth(3),
|
||||
1: FixedColumnWidth(100),
|
||||
|
|
@ -95,7 +97,6 @@ class MeasuresListWidget extends StatelessWidget {
|
|||
// 数据行
|
||||
for (var item in measuresList)
|
||||
TableRow(
|
||||
|
||||
children: [
|
||||
// 第一列:措施 + 签名 + 问题答案
|
||||
Padding(
|
||||
|
|
@ -117,7 +118,8 @@ class MeasuresListWidget extends StatelessWidget {
|
|||
|
||||
// 问题1~4 + 答案(可编辑或只读)
|
||||
for (var i = 1; i <= 4; i++)
|
||||
if ((item['QUESTION$i'] as String?)?.isNotEmpty ?? false)
|
||||
if ((item['QUESTION$i'] as String?)?.isNotEmpty ??
|
||||
false)
|
||||
_buildQnA(item, i),
|
||||
],
|
||||
),
|
||||
|
|
@ -131,40 +133,46 @@ class MeasuresListWidget extends StatelessWidget {
|
|||
// 签字或状态
|
||||
isAllowEdit
|
||||
? TextButton(
|
||||
onPressed: () {
|
||||
onSign?.call(item);
|
||||
},
|
||||
child: Text(
|
||||
(item['SIGN_ITEM'] ?? '').toString().isNotEmpty
|
||||
? '已签字'
|
||||
: '签字',
|
||||
style: TextStyle(
|
||||
color: (item['SIGN_ITEM'] ?? '').toString().isNotEmpty
|
||||
? Colors.grey.shade600
|
||||
: Colors.blue,
|
||||
),
|
||||
),
|
||||
)
|
||||
onPressed: () {
|
||||
onSign?.call(item);
|
||||
},
|
||||
child: Text(
|
||||
(item['SIGN_ITEM'] ?? '')
|
||||
.toString()
|
||||
.isNotEmpty
|
||||
? '已签字'
|
||||
: '签字',
|
||||
style: TextStyle(
|
||||
color:
|
||||
(item['SIGN_ITEM'] ?? '')
|
||||
.toString()
|
||||
.isNotEmpty
|
||||
? Colors.grey.shade600
|
||||
: Colors.blue,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
(item['STATUS'] as String?) == '-1'
|
||||
? '不涉及'
|
||||
: '涉及',
|
||||
style: TextStyle(
|
||||
color: (item['STATUS'] as String?) == '-1'
|
||||
? Colors.black
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
(item['STATUS'] as String?) == '-1'
|
||||
? '不涉及'
|
||||
: '涉及',
|
||||
style: TextStyle(
|
||||
color:
|
||||
(item['STATUS'] as String?) == '-1'
|
||||
? Colors.black
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
|
||||
// 操作图片
|
||||
if (item.containsKey('IMG_PATH') &&
|
||||
(item['IMG_PATH'] as String).isNotEmpty && isShowSign)
|
||||
(item['IMG_PATH'] as String).isNotEmpty &&
|
||||
isShowSign)
|
||||
..._buildImageRows(
|
||||
context,
|
||||
(item['IMG_PATH'] as String).split(','),
|
||||
'',
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -200,12 +208,16 @@ class MeasuresListWidget extends StatelessWidget {
|
|||
flex: 1,
|
||||
child: TextFormField(
|
||||
initialValue: answer,
|
||||
textAlign: TextAlign.center, // 输入文字居中对齐
|
||||
textAlign: TextAlign.center,
|
||||
// 输入文字居中对齐
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 4,
|
||||
),
|
||||
// 自定义边框颜色
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
|
|
@ -250,21 +262,23 @@ class MeasuresListWidget extends StatelessWidget {
|
|||
|
||||
/// 构造一组图片 + 可选时间文本行
|
||||
List<Widget> _buildImageRows(
|
||||
BuildContext context,
|
||||
List<String> paths,
|
||||
String time,
|
||||
) {
|
||||
BuildContext context,
|
||||
List<String> paths,
|
||||
String time,
|
||||
) {
|
||||
return paths.map((p) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center, // ← 这一行
|
||||
mainAxisAlignment: MainAxisAlignment.center, // ← 这一行
|
||||
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
presentOpaque(SingleImageViewer(imageUrl: '$baseImgPath$p'), context);
|
||||
|
||||
presentOpaque(
|
||||
SingleImageViewer(imageUrl: '$baseImgPath$p'),
|
||||
context,
|
||||
);
|
||||
},
|
||||
child: Image.network('$baseImgPath$p', width: 60, height: 60),
|
||||
),
|
||||
|
|
@ -397,7 +411,10 @@ class OtherMeasuresWidget extends StatelessWidget {
|
|||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
presentOpaque(SingleImageViewer(imageUrl: '$baseImgPath$path'), context);
|
||||
presentOpaque(
|
||||
SingleImageViewer(imageUrl: '$baseImgPath$path'),
|
||||
context,
|
||||
);
|
||||
},
|
||||
child: Image.network(
|
||||
'$baseImgPath$path',
|
||||
|
|
@ -602,9 +619,12 @@ class SignaturesListWidget extends StatelessWidget {
|
|||
children: [
|
||||
GestureDetector(
|
||||
onTap:
|
||||
() => presentOpaque(SingleImageViewer(
|
||||
imageUrl: '$baseImgPath${signPaths[i]}',
|
||||
), context),
|
||||
() => presentOpaque(
|
||||
SingleImageViewer(
|
||||
imageUrl: '$baseImgPath${signPaths[i]}',
|
||||
),
|
||||
context,
|
||||
),
|
||||
child: Image.network(
|
||||
'$baseImgPath${signPaths[i]}',
|
||||
width: 100,
|
||||
|
|
@ -693,9 +713,12 @@ class SignaturesListWidget extends StatelessWidget {
|
|||
children: [
|
||||
GestureDetector(
|
||||
onTap:
|
||||
() => presentOpaque(SingleImageViewer(
|
||||
imageUrl: '$baseImgPath${signPaths[i]}',
|
||||
), context),
|
||||
() => presentOpaque(
|
||||
SingleImageViewer(
|
||||
imageUrl: '$baseImgPath${signPaths[i]}',
|
||||
),
|
||||
context,
|
||||
),
|
||||
child: Image.network(
|
||||
'$baseImgPath${signPaths[i]}',
|
||||
width: 100,
|
||||
|
|
@ -738,6 +761,7 @@ class SelectionPopup extends StatefulWidget {
|
|||
@override
|
||||
_SelectionPopupState createState() => _SelectionPopupState();
|
||||
}
|
||||
|
||||
class _SelectionPopupState extends State<SelectionPopup> {
|
||||
late List<Map<String, String>> workList;
|
||||
late String selectedWorkType;
|
||||
|
|
@ -751,7 +775,7 @@ class _SelectionPopupState extends State<SelectionPopup> {
|
|||
super.initState();
|
||||
// 初始化作业类型列表
|
||||
workList = [
|
||||
{'WORK_TYPE': '', 'WORK_NAME': '作业选择'},
|
||||
{'WORK_TYPE': '', 'WORK_NAME': '请选择'},
|
||||
{'WORK_TYPE': 'HOTWORK', 'WORK_NAME': '动火作业'},
|
||||
{'WORK_TYPE': 'CONFINEDSPACE', 'WORK_NAME': '受限作业'},
|
||||
{'WORK_TYPE': 'HIGHWORK', 'WORK_NAME': '高处作业'},
|
||||
|
|
@ -772,20 +796,17 @@ class _SelectionPopupState extends State<SelectionPopup> {
|
|||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(_) => HDatePickerDialog(
|
||||
initialDate: DateTime.now(),
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: (selected) {
|
||||
Navigator.of(context).pop();
|
||||
setState(() {
|
||||
selectedDate = selected;
|
||||
});
|
||||
},
|
||||
),
|
||||
DateTime? picked = await BottomDateTimePicker.showDate(
|
||||
context,
|
||||
mode: BottomPickerMode.date, // 或 BottomPickerMode.dateTime(默认)
|
||||
allowFuture: true,
|
||||
minTimeStr: '0-0-0 00:00',
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
selectedDate = picked;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _getData() async {
|
||||
|
|
@ -795,7 +816,9 @@ class _SelectionPopupState extends State<SelectionPopup> {
|
|||
params = {
|
||||
'WORK_TYPE': selectedWorkType,
|
||||
'KEYWORDS':
|
||||
selectedDate == null ? '' : selectedDate!.toString().split(' ')[0],
|
||||
selectedDate == null
|
||||
? ''
|
||||
: DateFormat('yyyy-MM-dd').format(selectedDate!),
|
||||
'CORPINFO_ID': SessionService.instance.corpinfoId,
|
||||
};
|
||||
} else {
|
||||
|
|
@ -857,87 +880,137 @@ class _SelectionPopupState extends State<SelectionPopup> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5), // 圆角半径
|
||||
),
|
||||
backgroundColor: Colors.white,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 40),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 800,
|
||||
height: screenHeight(context),
|
||||
child: Column(
|
||||
children: [
|
||||
// 筛选栏
|
||||
if (widget.type == 'assignments')
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
child: Column(
|
||||
children: [
|
||||
// 作业类型下拉
|
||||
Expanded(
|
||||
child: DropdownButton<String>(
|
||||
dropdownColor: Colors.white,
|
||||
style: TextStyle(),
|
||||
isExpanded: true,
|
||||
value: selectedWorkName,
|
||||
items:
|
||||
workList
|
||||
.map(
|
||||
(e) => DropdownMenuItem(
|
||||
value: e['WORK_NAME'],
|
||||
child: Text(
|
||||
e['WORK_NAME']!,
|
||||
style: TextStyle(color: Colors.black87),
|
||||
Row(
|
||||
children: [
|
||||
// 作业类型下拉
|
||||
Text('作业类型:'),
|
||||
Expanded(
|
||||
child: DropdownButton<String>(
|
||||
dropdownColor: Colors.white,
|
||||
isExpanded: true,
|
||||
value: selectedWorkName,
|
||||
// 下拉列表里的项(菜单中居中)
|
||||
items:
|
||||
workList
|
||||
.map(
|
||||
(e) => DropdownMenuItem<String>(
|
||||
value: e['WORK_NAME'],
|
||||
child: Center(
|
||||
child: Text(
|
||||
e['WORK_NAME'] ?? '',
|
||||
style: TextStyle(
|
||||
color: Colors.black87,
|
||||
fontSize: 15,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
|
||||
// selectedItemBuilder 决定按钮上显示的已选项样式(在按钮上居中显示)
|
||||
selectedItemBuilder: (BuildContext context) {
|
||||
return workList.map<Widget>((e) {
|
||||
return Center(
|
||||
child: Text(
|
||||
e['WORK_NAME'] ?? '',
|
||||
style: TextStyle(
|
||||
color: Colors.black87,
|
||||
fontSize: 15,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
final idx = workList.indexWhere(
|
||||
(e) => e['WORK_NAME'] == v,
|
||||
);
|
||||
if (idx >= 0) {
|
||||
setState(() {
|
||||
selectedWorkType = workList[idx]['WORK_TYPE']!;
|
||||
selectedWorkName = v!;
|
||||
});
|
||||
_getData();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
|
||||
TextButton(
|
||||
onPressed: _pickDate,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
selectedDate == null
|
||||
? '选择作业申请时间'
|
||||
: selectedDate!.toString().split(' ')[0],
|
||||
style: TextStyle(color: Colors.blue),
|
||||
onChanged: (v) {
|
||||
final idx = workList.indexWhere(
|
||||
(e) => e['WORK_NAME'] == v,
|
||||
);
|
||||
if (idx >= 0) {
|
||||
setState(() {
|
||||
selectedWorkType =
|
||||
workList[idx]['WORK_TYPE']!;
|
||||
selectedWorkName = v!;
|
||||
});
|
||||
_getData();
|
||||
}
|
||||
},
|
||||
),
|
||||
SizedBox(width: 5),
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: Colors.grey,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(
|
||||
onPressed: _pickDate,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
selectedDate == null
|
||||
? '选择作业申请时间'
|
||||
: selectedDate!.toString().split(' ')[0],
|
||||
style: TextStyle(color: Colors.blue),
|
||||
),
|
||||
SizedBox(width: 5),
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: Colors.grey,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 清空
|
||||
CustomButton(
|
||||
text: '清空',
|
||||
padding: EdgeInsets.symmetric(horizontal: 15),
|
||||
height: 35,
|
||||
backgroundColor: Colors.blue,
|
||||
onPressed: _reset,
|
||||
Row(
|
||||
children: [
|
||||
// 清空
|
||||
Expanded(
|
||||
child: CustomButton(
|
||||
text: '搜索',
|
||||
padding: EdgeInsets.symmetric(horizontal: 15),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 0),
|
||||
height: 35,
|
||||
backgroundColor: Colors.green,
|
||||
onPressed: _getData,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10,),
|
||||
// 清空
|
||||
Expanded(
|
||||
child: CustomButton(
|
||||
text: '清空',
|
||||
margin: const EdgeInsets.symmetric(horizontal: 0),
|
||||
textStyle: TextStyle(color: Colors.black),
|
||||
padding: EdgeInsets.symmetric(horizontal: 15),
|
||||
height: 35,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
onPressed: _reset,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// 列表多选
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
|
|
@ -952,15 +1025,17 @@ class _SelectionPopupState extends State<SelectionPopup> {
|
|||
: item['NAME'] as String? ?? '';
|
||||
final checked = value.contains(key);
|
||||
return CheckboxListTile(
|
||||
controlAffinity: ListTileControlAffinity.leading, // <-- 把图标放左边
|
||||
contentPadding: EdgeInsets.zero, // 可选:移除左右默认 padding,使图标贴近左侧
|
||||
activeColor: Colors.blue,
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(key),
|
||||
Text(key, style: TextStyle(fontSize: 15)),
|
||||
if (widget.type == 'assignments') ...[
|
||||
Text('作业内容: ${item['WORK_CONTENT'] ?? ''}'),
|
||||
Text('作业负责人: ${item['CONFIRM_USER_NAME'] ?? ''}'),
|
||||
Text('作业申请时间: ${item['CREATTIME'] ?? ''}'),
|
||||
Text('作业内容: ${item['WORK_CONTENT'] ?? ''}', style: TextStyle(fontSize: 15),),
|
||||
Text('作业负责人: ${item['CONFIRM_USER_NAME'] ?? ''}', style: TextStyle(fontSize: 15)),
|
||||
Text('作业申请时间: ${item['CREATTIME'] ?? ''}', style: TextStyle(fontSize: 15)),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
|
@ -974,6 +1049,7 @@ class _SelectionPopupState extends State<SelectionPopup> {
|
|||
});
|
||||
},
|
||||
);
|
||||
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
@ -1009,5 +1085,3 @@ class _SelectionPopupState extends State<SelectionPopup> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ class _HotWorkDetailFormWidgetState extends State<HotWorkDetailFormWidget> {
|
|||
controller: widget.contentController,
|
||||
text: pd['WORK_CONTENT'] ?? '',
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
ItemListWidget.singleLineTitleText(
|
||||
label: '动火地点及动火部位:',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'dart:convert';
|
||||
import 'package:encrypt/encrypt.dart' as encrypt;
|
||||
import 'package:pointycastle/asymmetric/api.dart' show RSAPublicKey;
|
||||
import 'package:qhd_prevention/customWidget/toast_util.dart';
|
||||
import 'package:qhd_prevention/tools/tools.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
|
|
@ -31,9 +30,9 @@ class AuthService {
|
|||
|
||||
final data = await ApiService.loginCheck(encrypted);
|
||||
final result = data['result'] as String? ?? '';
|
||||
if (result != 'success'){
|
||||
Fluttertoast.showToast(msg:data['msg']);
|
||||
// ToastUtil.showNormal(context,data['msg']);
|
||||
if (result != 'success') {
|
||||
Fluttertoast.showToast(msg: data['msg'] ?? '');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import 'dart:io';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:video_compress/video_compress.dart';
|
||||
|
||||
class VideoConverter {
|
||||
/// 将视频转成 mp4 格式(如果本来就是 mp4 则直接返回原路径)
|
||||
static Future<String> convertToMp4(String inputPath) async {
|
||||
final ext = path.extension(inputPath).toLowerCase();
|
||||
|
||||
// 已经是 mp4,直接返回
|
||||
if (ext == '.mp4') {
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
try {
|
||||
print('开始转换: $inputPath');
|
||||
|
||||
// 压缩 + 转换格式(输出文件必然是 mp4)
|
||||
final MediaInfo? info = await VideoCompress.compressVideo(
|
||||
inputPath,
|
||||
quality: VideoQuality.DefaultQuality, // 可调: Low, Medium, High
|
||||
deleteOrigin: false, // 是否删除原文件
|
||||
includeAudio: true,
|
||||
);
|
||||
|
||||
if (info == null || info.path == null) {
|
||||
throw Exception('视频转换失败: $inputPath');
|
||||
}
|
||||
|
||||
print('转换完成: ${info.path}');
|
||||
return info.path!;
|
||||
} catch (e) {
|
||||
print('视频转换出错: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 将多个视频批量转换为 mp4
|
||||
static Future<List<String>> convertAllToMp4(List<String> videoPaths) async {
|
||||
final results = <String>[];
|
||||
for (final path in videoPaths) {
|
||||
final newPath = await convertToMp4(path);
|
||||
results.add(newPath);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
|
@ -16,11 +16,14 @@ int getRandomWithNum(int min, int max) {
|
|||
return random.nextInt(max - min + 1) + min; // 生成 [min, max] 的随机数
|
||||
}
|
||||
|
||||
double screenHeight(BuildContext context) {
|
||||
double screenHeight = MediaQuery.of(context).size.height;
|
||||
return screenHeight;
|
||||
}
|
||||
double screenWidth(BuildContext context) {
|
||||
double screenWidth = MediaQuery.of(context).size.width;
|
||||
return screenWidth;
|
||||
}
|
||||
|
||||
Future<T?> pushPage<T>(Widget page, BuildContext context) {
|
||||
return Navigator.push<T>(
|
||||
context,
|
||||
|
|
@ -194,6 +197,8 @@ class SessionService {
|
|||
String? customRecordDangerJson;
|
||||
String? unqualifiedInspectionItemID;
|
||||
String? listItemNameJson;
|
||||
String? studyToken;
|
||||
|
||||
|
||||
|
||||
/// 如果以下任何一项为空,则跳转到登录页
|
||||
|
|
@ -205,6 +210,7 @@ class SessionService {
|
|||
|
||||
// setters
|
||||
void setLoginUser(Map<String, dynamic> user) => loginUser = user;
|
||||
void setStudyToken(String token) => studyToken = token;
|
||||
|
||||
void setLoginUserId(String id) => loginUserId = id;
|
||||
|
||||
|
|
|
|||
356
pubspec.lock
356
pubspec.lock
File diff suppressed because it is too large
Load Diff
|
|
@ -106,7 +106,8 @@ dependencies:
|
|||
#百度地图
|
||||
flutter_baidu_mapapi_base: ^3.9.5
|
||||
flutter_baidu_mapapi_map: ^3.9.5
|
||||
|
||||
#文件处理
|
||||
video_compress: ^3.1.4
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
|
|
|||
Loading…
Reference in New Issue