兼容 IOS 端

This commit is contained in:
2026-06-03 23:37:02 +08:00
parent fb61e28e2f
commit 8f9f3a9779
20 changed files with 847 additions and 270 deletions

View File

@@ -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(),
),
);
},

View File

@@ -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,

View File

@@ -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,
);

View File

@@ -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(),
),
],
),
),
);
}
}

View File

@@ -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),
],
],
),

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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('当前平台不支持相机预览')),
);
}
}

View 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>