兼容 IOS 端
This commit is contained in:
@@ -5,7 +5,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_template/app/config/app_config.dart';
|
||||
import 'package:flutter_template/app/router/app_navigator.dart';
|
||||
import 'package:flutter_template/app/theme/app_theme.dart';
|
||||
import 'package:flutter_template/features/demo/demo_page.dart';
|
||||
import 'package:flutter_template/features/recording/recording_page.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
class FlutterTemplateApp extends StatelessWidget {
|
||||
@@ -44,7 +44,7 @@ class FlutterTemplateApp extends StatelessWidget {
|
||||
home: RefreshConfiguration(
|
||||
enableLoadingWhenNoData: false,
|
||||
headerTriggerDistance: 80,
|
||||
child: const DemoPage(),
|
||||
child: const RecordingPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -17,6 +17,24 @@ class PermissionService {
|
||||
return permissions.toList().request();
|
||||
}
|
||||
|
||||
/// 仅对尚未授予的权限发起系统授权弹窗,已授予则直接返回当前状态。
|
||||
static Future<Map<Permission, PermissionStatus>> requestMissing(
|
||||
Iterable<Permission> permissions,
|
||||
) async {
|
||||
final result = <Permission, PermissionStatus>{};
|
||||
for (final permission in permissions) {
|
||||
final current = await permission.status;
|
||||
if (current.isGranted ||
|
||||
current.isLimited ||
|
||||
current.isPermanentlyDenied) {
|
||||
result[permission] = current;
|
||||
continue;
|
||||
}
|
||||
result[permission] = await permission.request();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<bool> ensure(
|
||||
Permission permission, {
|
||||
bool openSettingsWhenPermanentlyDenied = true,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class DemoState {
|
||||
const DemoState({this.count = 0, this.query = ''});
|
||||
|
||||
final int count;
|
||||
final String query;
|
||||
|
||||
DemoState copyWith({int? count, String? query}) {
|
||||
return DemoState(count: count ?? this.count, query: query ?? this.query);
|
||||
}
|
||||
}
|
||||
|
||||
class DemoController extends Notifier<DemoState> {
|
||||
@override
|
||||
DemoState build() => const DemoState();
|
||||
|
||||
void increment() {
|
||||
state = state.copyWith(count: state.count + 1);
|
||||
}
|
||||
|
||||
void updateQuery(String query) {
|
||||
state = state.copyWith(query: query);
|
||||
}
|
||||
}
|
||||
|
||||
final demoControllerProvider = NotifierProvider<DemoController, DemoState>(
|
||||
DemoController.new,
|
||||
);
|
||||
@@ -1,130 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/app/config/app_config.dart';
|
||||
import 'package:flutter_template/app/theme/app_theme.dart';
|
||||
import 'package:flutter_template/features/demo/demo_controller.dart';
|
||||
import 'package:flutter_template/features/recording/recording_page.dart';
|
||||
import 'package:flutter_template/shared/widgets/widgets.dart';
|
||||
|
||||
class DemoPage extends ConsumerWidget {
|
||||
const DemoPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(demoControllerProvider);
|
||||
final controller = ref.read(demoControllerProvider.notifier);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text(AppConfig.appName)),
|
||||
body: SafeAreaWrapper(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
children: [
|
||||
AppSearchBar(hint: '搜索模板组件', onChanged: controller.updateQuery),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppCard(
|
||||
child: Row(
|
||||
children: [
|
||||
const AppAvatar(initials: 'T', size: 48),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'通用 Flutter 快速开发模板',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'已内置网络、缓存、路由、主题、权限、日志和常用 UI 组件。',
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: const [
|
||||
AppTag(label: 'Riverpod', tone: AppTagTone.info),
|
||||
AppTag(label: 'Dio', tone: AppTagTone.success),
|
||||
AppTag(label: '缓存', tone: AppTagTone.warning),
|
||||
AppTag(label: '无业务代码'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'状态管理示例',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text('当前计数:${state.count}'),
|
||||
if (state.query.isNotEmpty) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text('搜索关键字:${state.query}'),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
AppButton(
|
||||
label: '增加计数',
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
onPressed: controller.increment,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppButton(
|
||||
label: '打开录制',
|
||||
icon: const Icon(Icons.videocam, size: 18),
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const RecordingPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppStatusView(
|
||||
status: AppViewStatus.empty,
|
||||
empty: AppEmptyView(
|
||||
title: '空状态组件',
|
||||
message: '业务项目可替换图标、文案和操作按钮。',
|
||||
action: AppButton(
|
||||
label: '显示确认弹窗',
|
||||
variant: AppButtonVariant.outline,
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
onPressed: () async {
|
||||
final confirmed = await AppDialog.confirm(
|
||||
context,
|
||||
title: '模板弹窗',
|
||||
message: '这是可复用的确认弹窗示例。',
|
||||
);
|
||||
if (confirmed == true) {
|
||||
AppToast.show('已确认');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,9 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
// Allow PlatformView to attach before binding CameraX preview.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
if (!mounted) return;
|
||||
await ref.read(recordingSessionControllerProvider.notifier).prepareSession();
|
||||
await ref
|
||||
.read(recordingSessionControllerProvider.notifier)
|
||||
.prepareSession();
|
||||
}
|
||||
|
||||
Future<void> _enterRecordingMode() async {
|
||||
@@ -140,12 +142,6 @@ class _RecordingHud extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: state.isRecording
|
||||
? null
|
||||
: () => Navigator.of(context).maybePop(),
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
),
|
||||
const Spacer(),
|
||||
if (state.isRecording)
|
||||
Container(
|
||||
@@ -183,7 +179,10 @@ class _RecordingHud extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
state.permissionWarning!,
|
||||
style: const TextStyle(color: Colors.orangeAccent, fontSize: 12),
|
||||
style: const TextStyle(
|
||||
color: Colors.orangeAccent,
|
||||
fontSize: 12,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
@@ -220,7 +219,9 @@ class _RecordingHud extends StatelessWidget {
|
||||
color: state.isRecording ? Colors.white : Colors.red,
|
||||
),
|
||||
child: Icon(
|
||||
state.isRecording ? Icons.stop : Icons.fiber_manual_record,
|
||||
state.isRecording
|
||||
? Icons.stop
|
||||
: Icons.fiber_manual_record,
|
||||
color: state.isRecording ? Colors.red : Colors.white,
|
||||
size: 36,
|
||||
),
|
||||
@@ -280,16 +281,10 @@ class _SetupHints extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (!hasDndAccess)
|
||||
_HintChip(
|
||||
label: '开启勿扰权限可减少录制中断',
|
||||
onTap: onOpenDnd,
|
||||
),
|
||||
_HintChip(label: '开启勿扰权限可减少录制中断', onTap: onOpenDnd),
|
||||
if (!isBatteryIgnored) ...[
|
||||
const SizedBox(height: 8),
|
||||
_HintChip(
|
||||
label: '关闭电池优化可提升息屏续录稳定性',
|
||||
onTap: onOpenBattery,
|
||||
),
|
||||
_HintChip(label: '关闭电池优化可提升息屏续录稳定性', onTap: onOpenBattery),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -53,7 +53,12 @@ class RecordingPlatform {
|
||||
'com.example.flutter_template/recording_events',
|
||||
);
|
||||
|
||||
static bool get isSupported => Platform.isAndroid;
|
||||
static bool get isSupported =>
|
||||
supportsHost(isAndroid: Platform.isAndroid, isIOS: Platform.isIOS);
|
||||
|
||||
static bool supportsHost({required bool isAndroid, required bool isIOS}) {
|
||||
return isAndroid || isIOS;
|
||||
}
|
||||
|
||||
static Stream<RecordingStatus>? _statusStream;
|
||||
|
||||
@@ -61,9 +66,10 @@ class RecordingPlatform {
|
||||
if (!isSupported) {
|
||||
return const Stream.empty();
|
||||
}
|
||||
_statusStream ??= _events
|
||||
.receiveBroadcastStream()
|
||||
.map((event) => RecordingStatus.fromMap(Map<dynamic, dynamic>.from(event as Map)));
|
||||
_statusStream ??= _events.receiveBroadcastStream().map(
|
||||
(event) =>
|
||||
RecordingStatus.fromMap(Map<dynamic, dynamic>.from(event as Map)),
|
||||
);
|
||||
return _statusStream!;
|
||||
}
|
||||
|
||||
@@ -105,7 +111,8 @@ class RecordingPlatform {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> disposePreview() => _channel.invokeMethod('disposePreview');
|
||||
static Future<void> disposePreview() =>
|
||||
_channel.invokeMethod('disposePreview');
|
||||
|
||||
static Future<bool> hasNotificationPolicyAccess() async {
|
||||
return await _channel.invokeMethod<bool>('hasNotificationPolicyAccess') ??
|
||||
@@ -136,10 +143,9 @@ class RecordingPlatform {
|
||||
}
|
||||
|
||||
static Future<void> setImmersiveMode({required bool enabled}) {
|
||||
return _channel.invokeMethod(
|
||||
'setImmersiveMode',
|
||||
<String, dynamic>{'enabled': enabled},
|
||||
);
|
||||
return _channel.invokeMethod('setImmersiveMode', <String, dynamic>{
|
||||
'enabled': enabled,
|
||||
});
|
||||
}
|
||||
|
||||
static Future<RecordingStatus> getStatus() async {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/core/permission/permission_service.dart';
|
||||
import 'package:flutter_template/features/recording/recording_platform.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
@@ -91,11 +92,11 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final permissions = await <Permission>[
|
||||
final permissions = await PermissionService.requestMissing([
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
if (Platform.isAndroid) Permission.notification,
|
||||
].request();
|
||||
]);
|
||||
|
||||
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
|
||||
if (!cameraGranted) {
|
||||
|
||||
@@ -8,18 +8,27 @@ class CameraPreviewWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!Platform.isAndroid) {
|
||||
return const ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Center(child: Text('仅 Android 支持相机预览')),
|
||||
if (Platform.isAndroid) {
|
||||
return AndroidView(
|
||||
viewType: 'recording-camera-preview',
|
||||
layoutDirection: TextDirection.ltr,
|
||||
creationParams: const <String, dynamic>{},
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
);
|
||||
}
|
||||
|
||||
return AndroidView(
|
||||
viewType: 'recording-camera-preview',
|
||||
layoutDirection: TextDirection.ltr,
|
||||
creationParams: const <String, dynamic>{},
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
if (Platform.isIOS) {
|
||||
return UiKitView(
|
||||
viewType: 'recording-camera-preview',
|
||||
layoutDirection: TextDirection.ltr,
|
||||
creationParams: const <String, dynamic>{},
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
);
|
||||
}
|
||||
|
||||
return const ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Center(child: Text('当前平台不支持相机预览')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
56
lib/features/webview/test.html
Normal file
56
lib/features/webview/test.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>复制赛事录制码</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<button onclick="copyEventInfo()">复制赛事录制码</button>
|
||||
|
||||
<textarea id="copyText" style="position: fixed; left: -9999px;"></textarea>
|
||||
|
||||
<script>
|
||||
function copyEventInfo() {
|
||||
const data = {
|
||||
title: '王东方 丨李想 空中格斗赛',
|
||||
startTimestamp: 1717334400,
|
||||
endTimestamp: 1717334400,
|
||||
address: '广州市番禺区·粤港澳大湾区青年人才双创小镇',
|
||||
}
|
||||
|
||||
const jsonStr = JSON.stringify(data)
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(jsonStr)
|
||||
.then(() => {
|
||||
alert('赛事录制码已复制')
|
||||
})
|
||||
.catch(() => {
|
||||
fallbackCopy(jsonStr)
|
||||
})
|
||||
} else {
|
||||
fallbackCopy(jsonStr)
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text) {
|
||||
const textarea = document.getElementById('copyText')
|
||||
textarea.value = text
|
||||
textarea.select()
|
||||
textarea.setSelectionRange(0, textarea.value.length)
|
||||
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
alert('赛事录制码已复制')
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err)
|
||||
alert('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user