724 lines
21 KiB
Dart
724 lines
21 KiB
Dart
|
|
import 'dart:convert';
|
|||
|
|
import 'dart:math';
|
|||
|
|
import 'dart:async';
|
|||
|
|
import 'package:flutter/material.dart';
|
|||
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|||
|
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
|||
|
|
import 'package:flutter/services.dart';
|
|||
|
|
import 'dart:io';
|
|||
|
|
import 'package:image_picker/image_picker.dart';
|
|||
|
|
import 'package:permission_handler/permission_handler.dart';
|
|||
|
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
|||
|
|
import 'package:url_launcher/url_launcher.dart';
|
|||
|
|
import 'package:crypto/crypto.dart' as crypto;
|
|||
|
|
|
|||
|
|
int getRandomWithNum(int min, int max) {
|
|||
|
|
if (max < min) {
|
|||
|
|
// 保护性处理:交换或抛错,这里交换
|
|||
|
|
final tmp = min;
|
|||
|
|
min = max;
|
|||
|
|
max = tmp;
|
|||
|
|
}
|
|||
|
|
final random = Random();
|
|||
|
|
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,
|
|||
|
|
MaterialPageRoute(builder: (_) => page),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void presentOpaque(Widget page, BuildContext context) {
|
|||
|
|
Navigator.of(context).push(
|
|||
|
|
PageRouteBuilder(
|
|||
|
|
opaque: false, // 允许下层透出
|
|||
|
|
barrierColor: Colors.black.withOpacity(0.5), //路由遮罩色
|
|||
|
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
|||
|
|
transitionDuration: Duration.zero,
|
|||
|
|
reverseTransitionDuration: Duration.zero,
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class FocusHelper {
|
|||
|
|
static final FocusNode _emptyNode = FocusNode();
|
|||
|
|
/// 延迟一帧后再移交焦点,避免不生效的问题
|
|||
|
|
static void clearFocus(BuildContext context) {
|
|||
|
|
try{
|
|||
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|||
|
|
FocusScope.of(context).requestFocus(_emptyNode);
|
|||
|
|
await SystemChannels.textInput.invokeMethod('TextInput.hide');
|
|||
|
|
});
|
|||
|
|
}catch(w) {
|
|||
|
|
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 文本样式工具类,返回 Text Widget
|
|||
|
|
class HhTextStyleUtils {
|
|||
|
|
/// 主要标题,返回 Text
|
|||
|
|
/// [text]: 文本内容
|
|||
|
|
/// [color]: 文本颜色,默认黑色
|
|||
|
|
/// [fontSize]: 字体大小,默认16.0
|
|||
|
|
/// [bold]: 是否加粗,默认true
|
|||
|
|
static Text mainTitle(
|
|||
|
|
String text, {
|
|||
|
|
Color color = Colors.black,
|
|||
|
|
double fontSize = 14.0,
|
|||
|
|
bool bold = true,
|
|||
|
|
}) {
|
|||
|
|
return Text(
|
|||
|
|
text,
|
|||
|
|
style: TextStyle(
|
|||
|
|
color: color,
|
|||
|
|
fontSize: fontSize,
|
|||
|
|
fontWeight: bold ? FontWeight.bold : FontWeight.normal,
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static TextStyle secondaryTitleStyle = TextStyle(
|
|||
|
|
color: Colors.black54,
|
|||
|
|
fontSize: 13.0,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
/// 次要标题,返回 Text
|
|||
|
|
/// [text]: 文本内容
|
|||
|
|
/// [color]: 文本颜色,默认深灰
|
|||
|
|
/// [fontSize]: 字体大小,默认14.0
|
|||
|
|
/// [bold]: 是否加粗,默认false
|
|||
|
|
static Text secondaryTitle(
|
|||
|
|
String text, {
|
|||
|
|
Color color = Colors.black54,
|
|||
|
|
double fontSize = 12.0,
|
|||
|
|
bool bold = false,
|
|||
|
|
}) {
|
|||
|
|
return Text(
|
|||
|
|
text,
|
|||
|
|
style: TextStyle(
|
|||
|
|
color: color,
|
|||
|
|
fontSize: fontSize,
|
|||
|
|
fontWeight: bold ? FontWeight.bold : FontWeight.normal,
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 小文字,返回 Text
|
|||
|
|
/// [text]: 文本内容
|
|||
|
|
/// [color]: 文本颜色,默认灰色
|
|||
|
|
/// [fontSize]: 字体大小,默认12.0
|
|||
|
|
/// [bold]: 是否加粗,默认false
|
|||
|
|
static Text smallText(
|
|||
|
|
String text, {
|
|||
|
|
Color color = Colors.black54,
|
|||
|
|
double fontSize = 11.0,
|
|||
|
|
bool bold = false,
|
|||
|
|
}) {
|
|||
|
|
return Text(
|
|||
|
|
text,
|
|||
|
|
style: TextStyle(
|
|||
|
|
color: color,
|
|||
|
|
fontSize: fontSize,
|
|||
|
|
fontWeight: bold ? FontWeight.bold : FontWeight.normal,
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 版本信息模型类
|
|||
|
|
class AppVersionInfo {
|
|||
|
|
final String versionName; // 版本名称(如 1.0.0)
|
|||
|
|
final String buildNumber; // 构建号(如 1)
|
|||
|
|
final String fullVersion; // 完整版本(如 1.0.0+1)
|
|||
|
|
|
|||
|
|
AppVersionInfo({
|
|||
|
|
required this.versionName,
|
|||
|
|
required this.buildNumber,
|
|||
|
|
required this.fullVersion,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
String toString() {
|
|||
|
|
return fullVersion;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取应用版本信息的方法
|
|||
|
|
Future<AppVersionInfo> getAppVersion() async {
|
|||
|
|
try {
|
|||
|
|
final packageInfo = await PackageInfo.fromPlatform();
|
|||
|
|
return AppVersionInfo(
|
|||
|
|
versionName: packageInfo.version,
|
|||
|
|
buildNumber: packageInfo.buildNumber,
|
|||
|
|
fullVersion: '${packageInfo.version}+${packageInfo.buildNumber}',
|
|||
|
|
);
|
|||
|
|
} catch (e) {
|
|||
|
|
// 获取失败时返回默认值
|
|||
|
|
return AppVersionInfo(
|
|||
|
|
versionName: '1.0.0',
|
|||
|
|
buildNumber: '1',
|
|||
|
|
fullVersion: '1.0.0+0',
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// ------------------------------------------------------
|
|||
|
|
/// 日期格式化
|
|||
|
|
/// ------------------------------------------------------
|
|||
|
|
String formatDate(DateTime? date, String fmt) {
|
|||
|
|
if (date == null) return '';
|
|||
|
|
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
|||
|
|
|
|||
|
|
final replacements = <String, String>{
|
|||
|
|
'yyyy': date.year.toString(),
|
|||
|
|
'yy': date.year.toString().substring(2),
|
|||
|
|
'MM': twoDigits(date.month),
|
|||
|
|
'M': date.month.toString(),
|
|||
|
|
'dd': twoDigits(date.day),
|
|||
|
|
'd': date.day.toString(),
|
|||
|
|
'hh': twoDigits(date.hour),
|
|||
|
|
'h': date.hour.toString(),
|
|||
|
|
'mm': twoDigits(date.minute),
|
|||
|
|
'm': date.minute.toString(),
|
|||
|
|
'ss': twoDigits(date.second),
|
|||
|
|
's': date.second.toString(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
String result = fmt;
|
|||
|
|
replacements.forEach((key, value) {
|
|||
|
|
result = result.replaceAllMapped(RegExp(key), (_) => value);
|
|||
|
|
});
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
/// 把 'yyyy-MM-dd HH:mm'(或 'yyyy-MM-ddTHH:mm')解析为 DateTime,失败返回 null
|
|||
|
|
DateTime? _parseYMdHm(String s) {
|
|||
|
|
if (s.isEmpty) return null;
|
|||
|
|
String t = s.trim();
|
|||
|
|
|
|||
|
|
// 只包含日期的情况:yyyy-MM-dd
|
|||
|
|
if (RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(t)) {
|
|||
|
|
return DateTime.tryParse(t); // 默认 00:00:00
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// yyyy-MM-dd HH:mm 或 yyyy-MM-ddTHH:mm
|
|||
|
|
if (RegExp(r'^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}$').hasMatch(t)) {
|
|||
|
|
final iso = t.replaceFirst(' ', 'T') + ':00';
|
|||
|
|
return DateTime.tryParse(iso);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// yyyy-MM-dd HH:mm:ss 或 yyyy-MM-ddTHH:mm:ss
|
|||
|
|
if (RegExp(r'^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$').hasMatch(t)) {
|
|||
|
|
return DateTime.tryParse(t.replaceFirst(' ', 'T'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 都不匹配,返回 null
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
/// 比较两个 'yyyy-MM-dd HH:mm' 格式字符串
|
|||
|
|
/// 返回 1 (a>b), 0 (a==b), -1 (a<b)
|
|||
|
|
int compareYMdHmStrings(String a, String b) {
|
|||
|
|
final da = _parseYMdHm(a);
|
|||
|
|
final db = _parseYMdHm(b);
|
|||
|
|
|
|||
|
|
if (da == null || db == null) {
|
|||
|
|
throw FormatException("时间格式错误,期望 'yyyy-MM-dd HH:mm' 或 'yyyy-MM-dd HH:mm:ss'");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (da.isAtSameMomentAs(db)) return 0;
|
|||
|
|
return da.isAfter(db) ? 1 : -1;
|
|||
|
|
}
|
|||
|
|
/// 便捷:a 是否 晚于 b
|
|||
|
|
bool isAfterStr(String a, String b) => compareYMdHmStrings(a, b) == 1;
|
|||
|
|
|
|||
|
|
/// 便捷:a 是否 早于 b
|
|||
|
|
bool isBeforeStr(String a, String b) => compareYMdHmStrings(a, b) == -1;
|
|||
|
|
/// 判断传入时间字符串 (yyyy-MM-dd HH:mm) 是否早于当前时间
|
|||
|
|
bool isBeforeNow(String timeStr) {
|
|||
|
|
final dt = _parseYMdHm(timeStr);
|
|||
|
|
if (dt == null) {
|
|||
|
|
throw FormatException("时间格式错误,期望 'yyyy-MM-dd HH:mm' 或 'yyyy-MM-dd HH:mm:ss'");
|
|||
|
|
}
|
|||
|
|
return dt.isBefore(DateTime.now());
|
|||
|
|
}
|
|||
|
|
/// ------------------------------------------------------
|
|||
|
|
/// 防多次点击
|
|||
|
|
/// ------------------------------------------------------
|
|||
|
|
class ClickUtil {
|
|||
|
|
ClickUtil._();
|
|||
|
|
|
|||
|
|
static bool _canClick = true;
|
|||
|
|
|
|||
|
|
/// 调用示例:
|
|||
|
|
/// ClickUtil.noMultipleClicks(() { /* your code */ });
|
|||
|
|
static void noMultipleClicks(VoidCallback fn, {int delayMs = 2000}) {
|
|||
|
|
if (_canClick) {
|
|||
|
|
_canClick = false;
|
|||
|
|
fn();
|
|||
|
|
Future.delayed(Duration(milliseconds: delayMs), () {
|
|||
|
|
_canClick = true;
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
debugPrint('请稍后点击');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void presentPage(BuildContext context, Widget page) {
|
|||
|
|
Navigator.push(
|
|||
|
|
context,
|
|||
|
|
MaterialPageRoute(fullscreenDialog: true, builder: (_) => page),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
class LoadingDialogHelper {
|
|||
|
|
static Timer? _timer;
|
|||
|
|
|
|||
|
|
/// 显示加载框(带超时,默认 60 秒)
|
|||
|
|
static void show({String? message, Duration timeout = const Duration(seconds: 60)}) {
|
|||
|
|
// 先清理上一个计时器,避免重复
|
|||
|
|
_timer?.cancel();
|
|||
|
|
|
|||
|
|
if (message != null) {
|
|||
|
|
EasyLoading.show(status: message);
|
|||
|
|
} else {
|
|||
|
|
EasyLoading.show();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 设置超时自动隐藏
|
|||
|
|
_timer = Timer(timeout, () {
|
|||
|
|
// 保护性调用 dismiss(避免访问不存在的 isShow)
|
|||
|
|
try {
|
|||
|
|
EasyLoading.dismiss();
|
|||
|
|
} catch (e) {
|
|||
|
|
debugPrint('EasyLoading.dismiss error: $e');
|
|||
|
|
}
|
|||
|
|
_timer?.cancel();
|
|||
|
|
_timer = null;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 隐藏加载框(手动触发)
|
|||
|
|
static void hide() {
|
|||
|
|
// 清理计时器
|
|||
|
|
_timer?.cancel();
|
|||
|
|
_timer = null;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
EasyLoading.dismiss();
|
|||
|
|
} catch (e) {
|
|||
|
|
debugPrint('EasyLoading.dismiss error: $e');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
/// 将秒数转换为 “HH:MM:SS” 格式
|
|||
|
|
String secondsCount(dynamic seconds) {
|
|||
|
|
// 先尝试解析出一个 double 值
|
|||
|
|
double totalSeconds;
|
|||
|
|
if (seconds == null) {
|
|||
|
|
totalSeconds = 0;
|
|||
|
|
} else if (seconds is num) {
|
|||
|
|
totalSeconds = seconds.toDouble();
|
|||
|
|
} else {
|
|||
|
|
// seconds 是字符串或其他,尝试 parse
|
|||
|
|
totalSeconds = double.tryParse(seconds.toString()) ?? 0.0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 取整秒,向下取整
|
|||
|
|
final int secs = totalSeconds.floor();
|
|||
|
|
|
|||
|
|
final int h = (secs ~/ 3600) % 24;
|
|||
|
|
final int m = (secs ~/ 60) % 60;
|
|||
|
|
final int s = secs % 60;
|
|||
|
|
|
|||
|
|
// padLeft 保证两位数
|
|||
|
|
final String hh = h.toString().padLeft(2, '0');
|
|||
|
|
final String mm = m.toString().padLeft(2, '0');
|
|||
|
|
final String ss = s.toString().padLeft(2, '0');
|
|||
|
|
|
|||
|
|
return '$hh:$mm:$ss';
|
|||
|
|
}
|
|||
|
|
void printLongString(String text, {int chunkSize = 800}) {
|
|||
|
|
final pattern = RegExp('.{1,$chunkSize}'); // 每 chunkSize 个字符一组
|
|||
|
|
for (final match in pattern.allMatches(text)) {
|
|||
|
|
print(match.group(0));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 表单处理
|
|||
|
|
class FormUtils {
|
|||
|
|
/// 判断 [data] 中的 [key] 是否存在“有效值”:
|
|||
|
|
/// - key 不存在或值为 null -> false
|
|||
|
|
/// - String:去掉首尾空白后非空 -> true
|
|||
|
|
/// - Iterable / Map:非空 -> true
|
|||
|
|
/// - 其它类型(int、double、bool 等)只要不为 null 就算有值 -> true
|
|||
|
|
static bool hasValue(Map<dynamic, dynamic> data, String key) {
|
|||
|
|
if (!data.containsKey(key)) return false;
|
|||
|
|
final val = data[key];
|
|||
|
|
if (val == null) return false;
|
|||
|
|
|
|||
|
|
if (val is String) {
|
|||
|
|
return val.trim().isNotEmpty;
|
|||
|
|
}
|
|||
|
|
if (val is Iterable || val is Map) {
|
|||
|
|
return val.isNotEmpty;
|
|||
|
|
}
|
|||
|
|
// 数字、布尔等其它非空即可
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
/// 在list中根据一个 key,value,找到对应的map
|
|||
|
|
static Map<String, dynamic> findMapForKeyValue(
|
|||
|
|
List list,
|
|||
|
|
String key,
|
|||
|
|
dynamic value,
|
|||
|
|
) {
|
|||
|
|
// 保留原有行为:null 或 空字符串 返回空 Map
|
|||
|
|
if (value == null) return <String, dynamic>{};
|
|||
|
|
if (value is String && value.isEmpty) return <String, dynamic>{};
|
|||
|
|
|
|||
|
|
for (final item in list) {
|
|||
|
|
if (item is! Map) continue;
|
|||
|
|
final v = item[key];
|
|||
|
|
|
|||
|
|
if (v == null) continue;
|
|||
|
|
|
|||
|
|
// 1) 直接相等(类型相同或可直接比较)
|
|||
|
|
if (v == value) {
|
|||
|
|
return Map<String, dynamic>.from(item);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2) 数字与字符串的交叉比较("123" <-> 123)
|
|||
|
|
if (v is num && value is String) {
|
|||
|
|
final parsed = num.tryParse(value);
|
|||
|
|
if (parsed != null && parsed == v) {
|
|||
|
|
return Map<String, dynamic>.from(item);
|
|||
|
|
}
|
|||
|
|
} else if (v is String && (value is num)) {
|
|||
|
|
final parsed = num.tryParse(v);
|
|||
|
|
if (parsed != null && parsed == value) {
|
|||
|
|
return Map<String, dynamic>.from(item);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3) 最后回退到字符串比较(保险)
|
|||
|
|
if (v.toString() == value.toString()) {
|
|||
|
|
return Map<String, dynamic>.from(item);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return <String, dynamic>{};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class NoDataWidget {
|
|||
|
|
static Widget show({
|
|||
|
|
String text = '暂无数据',
|
|||
|
|
}) {
|
|||
|
|
return Center(
|
|||
|
|
child: Column(
|
|||
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|||
|
|
children: [
|
|||
|
|
Image.asset('assets/images/null.png', width: 200,),
|
|||
|
|
Text(text, style: TextStyle(color: Colors.grey)),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
}
|
|||
|
|
class NativeOrientation {
|
|||
|
|
static const MethodChannel _channel = MethodChannel('app.orientation');
|
|||
|
|
|
|||
|
|
static Future<bool> setLandscape() async {
|
|||
|
|
try {
|
|||
|
|
final res = await _channel.invokeMethod('setOrientation', 'landscape');
|
|||
|
|
return res == true;
|
|||
|
|
} on PlatformException catch (e) {
|
|||
|
|
debugPrint('PlatformException setLandscape: $e');
|
|||
|
|
return false;
|
|||
|
|
} catch (e) {
|
|||
|
|
debugPrint('Unknown error setLandscape: $e');
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static Future<bool> setPortrait() async {
|
|||
|
|
try {
|
|||
|
|
final res = await _channel.invokeMethod('setOrientation', 'portrait');
|
|||
|
|
return res == true;
|
|||
|
|
} on PlatformException catch (e) {
|
|||
|
|
debugPrint('PlatformException setPortrait: $e');
|
|||
|
|
return false;
|
|||
|
|
} catch (e) {
|
|||
|
|
debugPrint('Unknown error setPortrait: $e');
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class CameraPermissionHelper {
|
|||
|
|
static final ImagePicker _picker = ImagePicker();
|
|||
|
|
|
|||
|
|
// 检查并请求相机权限(使用 ImagePicker 触发权限请求)
|
|||
|
|
static Future<bool> checkAndRequestCameraPermission() async {
|
|||
|
|
if (Platform.isIOS) {
|
|||
|
|
// 对于 iOS,使用 ImagePicker 触发权限请求
|
|||
|
|
try {
|
|||
|
|
// 尝试获取图片(但立即取消)来触发权限请求
|
|||
|
|
final XFile? file = await _picker.pickImage(
|
|||
|
|
source: ImageSource.camera,
|
|||
|
|
maxWidth: 1, // 最小尺寸,减少处理时间
|
|||
|
|
maxHeight: 1,
|
|||
|
|
imageQuality: 1,
|
|||
|
|
).timeout(const Duration(milliseconds: 100), onTimeout: () {
|
|||
|
|
return null; // 超时返回 null,避免等待用户操作
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 无论是否成功获取文件,权限请求已经被触发
|
|||
|
|
// 现在检查实际的权限状态
|
|||
|
|
var status = await Permission.camera.status;
|
|||
|
|
return status.isGranted;
|
|||
|
|
} catch (e) {
|
|||
|
|
// 如果出现错误,回退到直接检查权限状态
|
|||
|
|
var status = await Permission.camera.status;
|
|||
|
|
return status.isGranted;
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// Android 使用标准的权限检查方式
|
|||
|
|
var status = await Permission.camera.status;
|
|||
|
|
if (status.isDenied) {
|
|||
|
|
status = await Permission.camera.request();
|
|||
|
|
}
|
|||
|
|
return status.isGranted;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查并请求相册权限
|
|||
|
|
static Future<bool> checkAndRequestPhotoPermission() async {
|
|||
|
|
if (Platform.isIOS) {
|
|||
|
|
// 对于 iOS,使用 ImagePicker 触发权限请求
|
|||
|
|
try {
|
|||
|
|
// 尝试获取图片(但立即取消)来触发权限请求
|
|||
|
|
final XFile? file = await _picker.pickImage(
|
|||
|
|
source: ImageSource.gallery,
|
|||
|
|
maxWidth: 1,
|
|||
|
|
maxHeight: 1,
|
|||
|
|
imageQuality: 1,
|
|||
|
|
).timeout(const Duration(milliseconds: 100), onTimeout: () {
|
|||
|
|
return null;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 检查实际的权限状态
|
|||
|
|
var status = await Permission.photos.status;
|
|||
|
|
return status.isGranted;
|
|||
|
|
} catch (e) {
|
|||
|
|
var status = await Permission.photos.status;
|
|||
|
|
return status.isGranted;
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// Android 使用标准的权限检查方式
|
|||
|
|
var status = await Permission.storage.status;
|
|||
|
|
if (status.isDenied) {
|
|||
|
|
status = await Permission.storage.request();
|
|||
|
|
}
|
|||
|
|
return status.isGranted;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
Future<bool> checkNetworkWifi() async {
|
|||
|
|
final connectivityResult = await Connectivity().checkConnectivity();
|
|||
|
|
if (connectivityResult == ConnectivityResult.mobile) {
|
|||
|
|
print("当前是移动网络(可能是 2G/3G/4G/5G)");
|
|||
|
|
return false;
|
|||
|
|
} else if (connectivityResult == ConnectivityResult.wifi) {
|
|||
|
|
return true;
|
|||
|
|
print("当前是 WiFi");
|
|||
|
|
} else if (connectivityResult == ConnectivityResult.ethernet) {
|
|||
|
|
print("当前是有线网络");
|
|||
|
|
} else if (connectivityResult == ConnectivityResult.none) {
|
|||
|
|
print("当前无网络连接");
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
|
|||
|
|
}
|
|||
|
|
Future<void> openAppStore() async {
|
|||
|
|
String appId = '6739233192';
|
|||
|
|
// 优先使用 itms-apps 直接打开 App Store 应用(iOS)
|
|||
|
|
final Uri uri = Uri.parse('itms-apps://itunes.apple.com/app/id$appId');
|
|||
|
|
// 可选:直接打开写评论页:
|
|||
|
|
// final Uri uri = Uri.parse('itms-apps://itunes.apple.com/app/id$appId?action=write-review');
|
|||
|
|
|
|||
|
|
if (await canLaunchUrl(uri)) {
|
|||
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|||
|
|
exit(0);
|
|||
|
|
} else {
|
|||
|
|
// 回退到 https 链接(在浏览器中打开 App Store 页面)
|
|||
|
|
final Uri webUri = Uri.parse('https://itunes.apple.com/app/id$appId');
|
|||
|
|
if (await canLaunchUrl(webUri)) {
|
|||
|
|
await launchUrl(webUri, mode: LaunchMode.externalApplication);
|
|||
|
|
} else {
|
|||
|
|
throw 'Could not launch App Store for app id $appId';
|
|||
|
|
}
|
|||
|
|
exit(0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// utils/sm2_format.dart
|
|||
|
|
const int C1C2C3 = 0;
|
|||
|
|
const int C1C3C2 = 1;
|
|||
|
|
|
|||
|
|
bool _looksLikeHex(String s) {
|
|||
|
|
final str = s.trim();
|
|||
|
|
return RegExp(r'^[0-9a-fA-F]+$').hasMatch(str) && str.length % 2 == 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 确保 SM2 密文的 C1 部分以 "04" 开头(hex 表示)。
|
|||
|
|
/// - cipherHex: SM2.encrypt 的返回值(hex 字符串)
|
|||
|
|
/// - 返回:如果输入看起来不是 hex,会直接返回原值;否则返回已处理的 hex 字符串。
|
|||
|
|
String ensureC1Has04(String cipherHex) {
|
|||
|
|
if (cipherHex == null) return cipherHex;
|
|||
|
|
String s = cipherHex.trim();
|
|||
|
|
if (!_looksLikeHex(s)) return s;
|
|||
|
|
|
|||
|
|
if (s.startsWith('04')) return s;
|
|||
|
|
|
|||
|
|
// 最小判断:如果长度足够(至少有 128(hex) 的 C1 + 64(hex) 的 C3)
|
|||
|
|
// 128 hex = 64 bytes (X+Y each 32 bytes), C3 = 32 bytes = 64 hex
|
|||
|
|
if (s.length >= 128 + 64) {
|
|||
|
|
// 直接在开头插 '04',把 c1 从 128->130 hex
|
|||
|
|
return '04' + s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 长度不符合常见 SM2 (可能是 base64 / 其他格式),不做改动
|
|||
|
|
return s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 去掉开头的 04(如果后端需要无 04 的 c1)
|
|||
|
|
String removeC1Prefix04(String cipherHex) {
|
|||
|
|
if (cipherHex == null) return cipherHex;
|
|||
|
|
String s = cipherHex.trim();
|
|||
|
|
if (!_looksLikeHex(s)) return s;
|
|||
|
|
if (s.startsWith('04') && s.length >= 130) {
|
|||
|
|
return s.substring(2);
|
|||
|
|
}
|
|||
|
|
return s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 尝试把输入视作 hex,并返回 hex 字符(或原样返回)
|
|||
|
|
String normalizeToHexIfPossible(dynamic input) {
|
|||
|
|
if (input == null) return '';
|
|||
|
|
if (input is String) {
|
|||
|
|
final s = input.trim();
|
|||
|
|
if (_looksLikeHex(s)) return s;
|
|||
|
|
// 如果是 base64,尝试 decode -> hex
|
|||
|
|
try {
|
|||
|
|
final bytes = base64.decode(s);
|
|||
|
|
return bytesToHex(bytes);
|
|||
|
|
} catch (e) {
|
|||
|
|
return s;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return input.toString();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 辅助:bytes -> hex
|
|||
|
|
String bytesToHex(List<int> bytes) {
|
|||
|
|
final sb = StringBuffer();
|
|||
|
|
for (final b in bytes) {
|
|||
|
|
sb.write(b.toRadixString(16).padLeft(2, '0'));
|
|||
|
|
}
|
|||
|
|
return sb.toString();
|
|||
|
|
}
|
|||
|
|
String normalizeSm2Hex(String hex) {
|
|||
|
|
hex = hex.replaceAll(RegExp(r'\s+'), '').toLowerCase();
|
|||
|
|
// 如果长度是 216 hex(108 bytes),很可能就是缺 0x04 的情况
|
|||
|
|
if (hex.length == 216) {
|
|||
|
|
return '04' + hex; // 返回 218 hex(109 bytes)
|
|||
|
|
}
|
|||
|
|
// 否则按原样返回(也可加更多校验)
|
|||
|
|
return hex;
|
|||
|
|
}
|
|||
|
|
String md5Hex(String input) {
|
|||
|
|
final bytes = utf8.encode(input);
|
|||
|
|
final digest = crypto.md5.convert(bytes);
|
|||
|
|
return digest.bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 56个民族数据字典
|
|||
|
|
List<Map<String, String>> nationMapList = [
|
|||
|
|
{"code": "01", "name": "汉族"},
|
|||
|
|
{"code": "02", "name": "蒙古族"},
|
|||
|
|
{"code": "03", "name": "回族"},
|
|||
|
|
{"code": "04", "name": "藏族"},
|
|||
|
|
{"code": "05", "name": "维吾尔族"},
|
|||
|
|
{"code": "06", "name": "苗族"},
|
|||
|
|
{"code": "07", "name": "彝族"},
|
|||
|
|
{"code": "08", "name": "壮族"},
|
|||
|
|
{"code": "09", "name": "布依族"},
|
|||
|
|
{"code": "10", "name": "朝鲜族"},
|
|||
|
|
{"code": "11", "name": "满族"},
|
|||
|
|
{"code": "12", "name": "侗族"},
|
|||
|
|
{"code": "13", "name": "瑶族"},
|
|||
|
|
{"code": "14", "name": "白族"},
|
|||
|
|
{"code": "15", "name": "土家族"},
|
|||
|
|
{"code": "16", "name": "哈尼族"},
|
|||
|
|
{"code": "17", "name": "哈萨克族"},
|
|||
|
|
{"code": "18", "name": "傣族"},
|
|||
|
|
{"code": "19", "name": "黎族"},
|
|||
|
|
{"code": "20", "name": "傈僳族"},
|
|||
|
|
{"code": "21", "name": "佤族"},
|
|||
|
|
{"code": "22", "name": "畲族"},
|
|||
|
|
{"code": "23", "name": "高山族"},
|
|||
|
|
{"code": "24", "name": "拉祜族"},
|
|||
|
|
{"code": "25", "name": "水族"},
|
|||
|
|
{"code": "26", "name": "东乡族"},
|
|||
|
|
{"code": "27", "name": "纳西族"},
|
|||
|
|
{"code": "28", "name": "景颇族"},
|
|||
|
|
{"code": "29", "name": "柯尔克孜族"},
|
|||
|
|
{"code": "30", "name": "土族"},
|
|||
|
|
{"code": "31", "name": "达斡尔族"},
|
|||
|
|
{"code": "32", "name": "仫佬族"},
|
|||
|
|
{"code": "33", "name": "羌族"},
|
|||
|
|
{"code": "34", "name": "布朗族"},
|
|||
|
|
{"code": "35", "name": "撒拉族"},
|
|||
|
|
{"code": "36", "name": "毛南族"},
|
|||
|
|
{"code": "37", "name": "仡佬族"},
|
|||
|
|
{"code": "38", "name": "锡伯族"},
|
|||
|
|
{"code": "39", "name": "阿昌族"},
|
|||
|
|
{"code": "40", "name": "普米族"},
|
|||
|
|
{"code": "41", "name": "塔吉克族"},
|
|||
|
|
{"code": "42", "name": "怒族"},
|
|||
|
|
{"code": "43", "name": "乌孜别克族"},
|
|||
|
|
{"code": "44", "name": "俄罗斯族"},
|
|||
|
|
{"code": "45", "name": "鄂温克族"},
|
|||
|
|
{"code": "46", "name": "德昂族"},
|
|||
|
|
{"code": "47", "name": "保安族"},
|
|||
|
|
{"code": "48", "name": "裕固族"},
|
|||
|
|
{"code": "49", "name": "京族"},
|
|||
|
|
{"code": "50", "name": "塔塔尔族"},
|
|||
|
|
{"code": "51", "name": "独龙族"},
|
|||
|
|
{"code": "52", "name": "鄂伦春族"},
|
|||
|
|
{"code": "53", "name": "赫哲族"},
|
|||
|
|
{"code": "54", "name": "门巴族"},
|
|||
|
|
{"code": "55", "name": "珞巴族"},
|
|||
|
|
{"code": "56", "name": "基诺族"}
|
|||
|
|
];
|