动我项目仓库 flutter:3.22 dart:3.4.4
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

360 lines
10 KiB

// global_modal.dart
// 全局弹窗管理器
// 简要说明:使用overlay挂载在全局widget上,以此实现对全局的覆盖,
// overlay为全局独立的一个层级,dialog等通常也是 由他实现
import 'dart:async';
import 'dart:collection';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
// ==========================
// 1) 公共类型定义
// ==========================
typedef ModalBuilder = Widget Function(
BuildContext context,
ModalRequest req,
void Function(ModalResult) complete,
);
enum ModalPriority { low, normal, high, critical }
class ModalRequest {
final String type; // 业务类型:'friend_request' | 'live_invite' | ...
final Map<String, dynamic> payload;// 自定义业务数据
final String? dedupeKey; // 去重键(如 'friendReq:123')
final ModalPriority priority; // 优先级
final Duration? ttl; // 过期丢弃窗口(从创建到入队/处理)
final Duration? timeout; // 自动超时
final DateTime createdAt = DateTime.now();
ModalRequest({
required this.type,
required this.payload,
this.dedupeKey,
this.priority = ModalPriority.normal,
this.ttl,
this.timeout,
});
}
class ModalResult {
final String action; // 'accept' | 'decline' | 'timeout' | 'dismiss' | 'expired' | 'deduped' | 'unknown_type'
final Map<String, dynamic>? data;
const ModalResult(this.action, {this.data});
}
// ==========================
// 2) 全局控制器 + Host + 队列
// ==========================
class GlobalModalController {
GlobalModalController._();
static final GlobalModalController I = GlobalModalController._();
GlobalKey<OverlayState>? _overlayKey;
void setOverlayKey(GlobalKey<OverlayState> key) {
_overlayKey = key;
_pump(); // key 就绪后尝试展示队列
}
final Map<String, ModalBuilder> _registry = {};
final PriorityQueue<_QueueItem> _queue = PriorityQueue(
(a, b) {
final prio = b.req.priority.index.compareTo(a.req.priority.index);
if (prio != 0) return prio;
return a.idx.compareTo(b.idx); // 同优先级按入队序
},
);
int _seq = 0;
Completer<ModalResult>? _activeCompleter;
OverlayEntry? _entry;
BuildContext? _hostContext;
bool _isForeground = true;
final Map<String, DateTime> _recentKeys = {};
void attachHost(BuildContext ctx) {
_hostContext = ctx;
_pump();
}
void register(String type, ModalBuilder builder) {
_registry[type] = builder;
}
Future<ModalResult> enqueue(ModalRequest req) async {
if (req.ttl != null && DateTime.now().difference(req.createdAt) > req.ttl!) {
return const ModalResult('expired');
}
if (req.dedupeKey != null) {
final last = _recentKeys[req.dedupeKey!];
if (last != null && DateTime.now().difference(last) < const Duration(seconds: 10)) {
return const ModalResult('deduped');
}
_recentKeys[req.dedupeKey!] = DateTime.now();
}
final c = Completer<ModalResult>();
_queue.add(_QueueItem(idx: _seq++, req: req, completer: c));
_pump();
return c.future;
}
void _pump() {
if (_activeCompleter != null || _overlayKey?.currentState == null || !_isForeground) return;
if (_queue.isEmpty) return;
final item = _queue.removeFirst();
final builder = _registry[item.req.type];
if (builder == null) {
item.completer.complete(const ModalResult('unknown_type'));
_pump();
return;
}
_activeCompleter = item.completer;
void onComplete(ModalResult r) {
_dismiss();
_activeCompleter?.complete(r);
_activeCompleter = null;
_pump();
}
Timer? to;
if (item.req.timeout != null) {
to = Timer(item.req.timeout!, () {
if (_activeCompleter != null) onComplete(const ModalResult('timeout'));
});
}
_entry = OverlayEntry(
maintainState: true,
builder: (ctx) => _ModalScope(
child: builder(ctx, item.req, (r) {
to?.cancel();
onComplete(r);
}),
onBackgroundTap: () {
to?.cancel();
onComplete(const ModalResult('dismiss'));
},
),
);
_overlayKey!.currentState!.insert(_entry!);
}
void _dismiss() {
// ✅ 防御性处理:只有在 entry 仍然挂载时才移除
try {
_entry?.remove();
} catch (_) {}
_entry = null;
}
void setForeground(bool fg) {
_isForeground = fg;
if (fg) _pump();
}
}
class GlobalModalHost extends StatefulWidget {
final Widget child;
const GlobalModalHost({super.key, required this.child});
@override
State<GlobalModalHost> createState() => _GlobalModalHostState();
}
class _GlobalModalHostState extends State<GlobalModalHost> with WidgetsBindingObserver {
final GlobalKey<OverlayState> _overlayKey = GlobalKey<OverlayState>();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
GlobalModalController.I.setOverlayKey(_overlayKey);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
GlobalModalController.I.attachHost(context);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
GlobalModalController.I.setForeground(state == AppLifecycleState.resumed);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Overlay(
key: _overlayKey,
initialEntries: [
OverlayEntry(builder: (_) => widget.child),
],
);
}
}
class _QueueItem {
final int idx;
final ModalRequest req;
final Completer<ModalResult> completer;
_QueueItem({required this.idx, required this.req, required this.completer});
}
// ==========================
// 3) 通用弹窗壳
// ==========================
class _ModalScope extends StatelessWidget {
final Widget child;
final VoidCallback onBackgroundTap;
const _ModalScope({required this.child, required this.onBackgroundTap});
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onBackgroundTap,
child: AnimatedOpacity(
opacity: 1,
duration: const Duration(milliseconds: 150),
child: Container(color: Colors.black54),
),
),
),
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Card(
elevation: 12,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: child,
),
),
),
),
],
);
}
}
// ==========================
// 4) 典型业务弹窗注册(好友申请 / 直播连麦)
// ==========================
void registerGlobalModals() {
final gm = GlobalModalController.I;
gm.register('friend_request', (context, req, complete) {
final userName = req.payload['userName'] as String? ?? '某位用户';
final avatar = req.payload['avatar'] as String?;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (avatar != null) ...[
CircleAvatar(radius: 28, backgroundImage: NetworkImage(avatar)),
const SizedBox(height: 12),
],
Text('$userName 请求添加你为好友', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
const Text('是否接受?'),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => complete(const ModalResult('decline')),
child: const Text('拒绝'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () => complete(const ModalResult('accept')),
child: const Text('同意'),
),
),
],
)
],
);
});
gm.register('live_invite', (context, req, complete) {
final roomName = req.payload['roomName'] ?? '直播间';
final inviter = req.payload['inviter'] ?? '主播';
final countdown = ValueNotifier<int>(req.payload['countdownSec'] ?? 15);
Timer? t;
t = Timer.periodic(const Duration(seconds: 1), (_) {
countdown.value = (countdown.value - 1).clamp(0, 999);
if (countdown.value == 0) {
t?.cancel();
complete(const ModalResult('timeout'));
}
});
return StatefulBuilder(
builder: (context, setState) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('$inviter 邀请你加入 $roomName 连麦', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
ValueListenableBuilder<int>(
valueListenable: countdown,
builder: (_, v, __) => Text('将于 $v 秒后自动取消'),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
t?.cancel();
complete(const ModalResult('decline'));
},
child: const Text('稍后'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () {
t?.cancel();
complete(const ModalResult('accept'));
},
child: const Text('立即加入'),
),
),
],
)
],
);
},
);
});
}