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
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('立即加入'),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
],
|
|
);
|
|
},
|
|
);
|
|
});
|
|
}
|