import 'dart:async'; import 'dart:io'; import 'package:audioplayers/audioplayers.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/services.dart'; import 'package:geolocator/geolocator.dart'; import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; import '../api/oa_client.dart'; /// 系统截屏、高刷、录音、选图(保留 GIF)。 class DeviceBridge { DeviceBridge._(); static const _ch = EventChannel('com.fysxkj.oa/device'); static const _m = MethodChannel('com.fysxkj.oa/ota'); static StreamSubscription? _sub; static final _cbs = {}; static final AudioPlayer _player = AudioPlayer(); static final AudioRecorder _recorder = AudioRecorder(); static String? _recordingPath; static DateTime? _recordingStarted; static String? _playingPath; static OaClient? _api; static void bindApi(OaClient api) => _api = api; static bool get _useNativeBridge => Platform.isAndroid; static Future preferMaxRefresh() async { if (!_useNativeBridge) return; try { await _m.invokeMethod('preferMaxRefresh'); } catch (_) {} } static Timer? _clipPoll; static bool _clipHadImage = false; static int _lastClipAt = 0; static void watchScreenshot(void Function() onShot) { _cbs.add(onShot); if (Platform.isAndroid || Platform.isWindows) { _sub ??= _ch.receiveBroadcastStream().listen((event) { if (event == 'screenshot') { for (final cb in [..._cbs]) { cb(); } } }); _m.invokeMethod('startScreenshotWatch').catchError((_) {}); } else if (Platform.isLinux) { _startLinuxScreenshotPoll(); } } static void _startLinuxScreenshotPoll() { _clipPoll ??= Timer.periodic(const Duration(milliseconds: 900), (_) async { final now = DateTime.now().millisecondsSinceEpoch; if (now - _lastClipAt < 1500) return; final has = await _linuxClipboardHasImage(); if (has && !_clipHadImage) { _clipHadImage = true; _lastClipAt = now; for (final cb in [..._cbs]) { cb(); } } else if (!has) { _clipHadImage = false; } }); } static Future _linuxClipboardHasImage() async { try { final x = await Process.run('bash', [ '-c', 'command -v xclip >/dev/null && xclip -selection clipboard -t TARGETS -o 2>/dev/null | grep -qi image && echo 1; ' 'command -v wl-paste >/dev/null && wl-paste --list-types 2>/dev/null | grep -qi image && echo 1', ]); return '${x.stdout}'.contains('1'); } catch (_) { return false; } } static void unwatchScreenshot(void Function() onShot) { _cbs.remove(onShot); if (_cbs.isNotEmpty) return; if (Platform.isAndroid || Platform.isWindows) { _m.invokeMethod('stopScreenshotWatch').catchError((_) {}); _sub?.cancel(); _sub = null; } _clipPoll?.cancel(); _clipPoll = null; _clipHadImage = false; } static Future startVoice() async { if (_useNativeBridge) { await _m.invokeMethod('startVoice'); return; } if (!await _recorder.hasPermission()) { throw Exception('没有麦克风权限'); } final dir = await getTemporaryDirectory(); _recordingPath = '${dir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.m4a'; _recordingStarted = DateTime.now(); await _recorder.start(const RecordConfig(encoder: AudioEncoder.aacLc), path: _recordingPath!); } static Future?> stopVoice() async { if (_useNativeBridge) { final raw = await _m.invokeMethod('stopVoice'); if (raw is Map) return Map.from(raw); return null; } final path = await _recorder.stop(); _recordingPath = null; if (path == null || path.isEmpty) return null; final started = _recordingStarted; _recordingStarted = null; final seconds = started == null ? 1 : DateTime.now().difference(started).inSeconds.clamp(1, 60); return {'path': path, 'seconds': seconds}; } static Future cancelVoice() async { if (_useNativeBridge) { try { await _m.invokeMethod('cancelVoice'); } catch (_) {} return; } try { await _recorder.stop(); } catch (_) {} _recordingPath = null; _recordingStarted = null; } static Future playVoice(String path) async { if (_useNativeBridge) { await _m.invokeMethod('playVoice', {'path': path}); return; } await _player.stop(); _playingPath = path; await _player.play(DeviceFileSource(path)); } static Future stopPlay() async { if (_useNativeBridge) { try { await _m.invokeMethod('stopPlay'); } catch (_) {} return; } await _player.stop(); _playingPath = null; } static String? get playingPath => _playingPath; /// 手机端本地语音转文字(不调 OA 服务端)。 static Future speechToText({required String audioPath}) async { if (!_useNativeBridge) return null; final value = await _m.invokeMethod('speechToText', {'path': audioPath}); final text = '$value'.trim(); return value == null || text.isEmpty || text == 'null' ? null : text; } static final AudioPlayer _tonePlayer = AudioPlayer(); static Future startCallTone({bool incoming = false}) async { if (_useNativeBridge) { try { await _m.invokeMethod('startCallTone', {'incoming': incoming}); } catch (_) {} return; } try { await _tonePlayer.setReleaseMode(ReleaseMode.loop); await _tonePlayer.play(AssetSource('sounds/ring.wav')); } catch (_) {} } static Future stopCallTone() async { if (_useNativeBridge) { try { await _m.invokeMethod('stopCallTone'); } catch (_) {} return; } try { await _tonePlayer.stop(); } catch (_) {} } static Future cancelCallNotification() async { if (!_useNativeBridge) return; try { await _m.invokeMethod('cancelCallNotification'); } catch (_) {} } static Future playMessageSent() async { if (!_useNativeBridge) return; try { await _m.invokeMethod('playMessageSent'); } catch (_) {} } static Future?> getLocation() async { if (_useNativeBridge) { final raw = await _m.invokeMethod('getLocation'); if (raw is Map) { final lat = (raw['lat'] as num?)?.toDouble(); final lng = (raw['lng'] as num?)?.toDouble(); if (lat != null && lng != null) return {'lat': lat, 'lng': lng}; } return null; } try { final serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) return null; var perm = await Geolocator.checkPermission(); if (perm == LocationPermission.denied) { perm = await Geolocator.requestPermission(); } if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) { return null; } final pos = await Geolocator.getCurrentPosition(locationSettings: const LocationSettings(accuracy: LocationAccuracy.high)); return {'lat': pos.latitude, 'lng': pos.longitude}; } catch (_) { return null; } } static Future?> pickFile() async { if (_useNativeBridge) { final raw = await _m.invokeMethod('pickFile'); if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v')); return null; } final picked = await FilePicker.platform.pickFiles(allowMultiple: false); if (picked == null || picked.files.isEmpty) return null; final f = picked.files.first; final path = f.path; if (path == null || path.isEmpty) return null; return {'path': path, 'name': f.name, 'mime': f.extension ?? ''}; } static Future openFile(String path, {String? mime}) async { if (_useNativeBridge) { await _m.invokeMethod('openFile', {'path': path, 'mime': mime ?? ''}); return; } if (Platform.isLinux) { await Process.run('xdg-open', [path]); } else if (Platform.isWindows) { await Process.start('cmd', ['/c', 'start', '', path], mode: ProcessStartMode.detached); } } static Future saveImage(String path) async { if (_useNativeBridge) { await _m.invokeMethod('saveImage', {'path': path}); return; } final name = path.split('/').last; final picked = await FilePicker.platform.saveFile(dialogTitle: '保存图片', fileName: name.isEmpty ? 'image.jpg' : name); if (picked == null || picked.isEmpty) return; await File(path).copy(picked); } static Future openUrl(String url) async { if (_useNativeBridge) { await _m.invokeMethod('openUrl', {'url': url}); return; } if (Platform.isLinux) { await Process.run('xdg-open', [url]); } else if (Platform.isWindows) { await Process.start('cmd', ['/c', 'start', '', url], mode: ProcessStartMode.detached); } } static Future requestCallMedia() async { if (!_useNativeBridge) return true; try { final ok = await _m.invokeMethod('requestCallMedia'); return ok == true; } catch (_) { return true; } } static Future?> pickMedia() async { if (_useNativeBridge) { final raw = await _m.invokeMethod('pickMedia'); if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v')); return null; } final picked = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'], allowMultiple: false, ); if (picked == null || picked.files.isEmpty) return null; final f = picked.files.first; final path = f.path; if (path == null || path.isEmpty) return null; final ext = (f.extension ?? 'jpg').toLowerCase(); final mime = ext == 'png' ? 'image/png' : ext == 'gif' ? 'image/gif' : ext == 'webp' ? 'image/webp' : 'image/jpeg'; return {'path': path, 'name': f.name, 'mime': mime}; } }