Files
Developer f91be94e9c refactor: 完成项目架构重构,统一模块导入路径
- 清理大量废弃的 barrel 导出文件,移除冗余的中间导出层
- 修复所有相对路径导入错误,统一调整为扁平化模块引用
- 更新多平台 pubspec 版本号与依赖库版本
- 补充后端功能问题管理后台与脚本工具
- 调整部分页面的快捷方式文案适配新功能
- 更新部分翻译覆盖率与API文档
2026-06-12 08:53:57 +08:00

472 lines
14 KiB
Dart

// ============================================================
// 闲言APP — 协作画布全屏页面
// 创建时间: 2026-05-14
// 更新时间: 2026-06-05
// 作用: 全屏协作画布,支持绘制/同步/导出PNG/参与者显示/远程光标/快照同步
// 上次更新: Web兼容—getTemporaryDirectory添加kIsWeb保护
// ============================================================
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:xianyan/core/theme/app_theme.dart';
import 'package:xianyan/core/theme/app_spacing.dart';
import 'package:xianyan/core/theme/app_typography.dart';
import 'package:xianyan/core/theme/app_radius.dart';
import 'package:xianyan/shared/widgets/adaptive/adaptive_back_button.dart';
import 'package:xianyan/core/utils/logger.dart';
import 'package:xianyan/features/file_transfer/providers/shared_signaling_provider.dart';
import 'canvas_provider.dart';
import 'widgets/canvas_painter.dart';
import 'widgets/canvas_toolbar.dart';
class CanvasPage extends ConsumerStatefulWidget {
const CanvasPage({
super.key,
required this.canvasId,
this.peerDeviceId,
this.userId = '',
});
final String canvasId;
final String? peerDeviceId;
final String userId;
@override
ConsumerState<CanvasPage> createState() => _CanvasPageState();
}
class _CanvasPageState extends ConsumerState<CanvasPage> {
final _repaintBoundaryKey = GlobalKey();
CanvasNotifier? _cachedNotifier;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final notifier = ref.read(canvasProvider.notifier);
_cachedNotifier = notifier;
notifier.setUserId(widget.userId);
final deviceId =
ref.read(sharedSignalingProvider).deviceId ?? widget.userId;
notifier.joinCanvas(
widget.canvasId,
deviceId,
peerDeviceId: widget.peerDeviceId,
);
});
}
@override
void dispose() {
Future.microtask(() {
_cachedNotifier?.leaveCanvas();
}).catchError((_) {});
_cachedNotifier = null;
super.dispose();
}
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
final canvasState = ref.watch(canvasProvider);
final notifier = ref.read(canvasProvider.notifier);
return CupertinoPageScaffold(
backgroundColor: ext.bgPrimary,
navigationBar: CupertinoNavigationBar(
backgroundColor: ext.bgPrimary.withValues(alpha: 0.85),
border: null,
leading: const AdaptiveBackButton(),
middle: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('🎨', style: TextStyle(fontSize: 16)),
const SizedBox(width: AppSpacing.xs),
Text(
'协作画布',
style: AppTypography.headline.copyWith(color: ext.textPrimary),
),
],
),
trailing: _buildParticipantsTrailing(ext, canvasState),
),
child: SafeArea(
child: Column(
children: [
if (canvasState.isConnected)
_buildSyncStatusBar(ext, canvasState, notifier),
Expanded(
child: RepaintBoundary(
key: _repaintBoundaryKey,
child: Container(
color: CupertinoColors.white,
child: Stack(
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (details) {
notifier.startStroke(details.localPosition);
},
onPanUpdate: (details) {
notifier.addPoint(details.localPosition);
},
onPanEnd: (_) {
notifier.endStroke();
},
child: CustomPaint(
painter: CanvasPainter(
strokes: canvasState.strokes,
activeStroke: canvasState.activeStroke,
),
size: Size.infinite,
),
),
_buildCursorOverlay(canvasState),
],
),
),
),
),
CanvasToolbar(
currentTool: canvasState.currentTool,
currentColor: canvasState.currentColor,
currentWidth: canvasState.currentWidth,
canUndo: notifier.engine.canUndo,
canRedo: notifier.engine.canRedo,
onToolChanged: (tool) => notifier.setTool(tool),
onColorChanged: (color) => notifier.setColor(color),
onWidthChanged: (width) => notifier.setWidth(width),
onUndo: () => notifier.undo(),
onRedo: () => notifier.redo(),
onExport: _exportPng,
onClear: _confirmClear,
),
],
),
),
);
}
Widget _buildCursorOverlay(CanvasState canvasState) {
final cursors = canvasState.remoteCursors;
final opacities = canvasState.cursorOpacities;
if (cursors.isEmpty) return const SizedBox.shrink();
return Stack(
children: cursors.entries.map((entry) {
final userId = entry.key;
final position = entry.value;
final opacity = opacities[userId] ?? 0.0;
if (opacity <= 0.0) return const SizedBox.shrink();
final color = _cursorColorForUser(userId);
final initial = _cursorInitialForUser(userId);
return Positioned(
left: position.dx,
top: position.dy,
child: AnimatedOpacity(
opacity: opacity,
duration: const Duration(milliseconds: 500),
child: _RemoteCursorWidget(
color: color,
initial: initial,
),
),
);
}).toList(),
);
}
Color _cursorColorForUser(String userId) {
final colors = [
const Color(0xFF6C63FF),
const Color(0xFFEF4444),
const Color(0xFFF59E0B),
const Color(0xFF10B981),
const Color(0xFF3B82F6),
const Color(0xFFEC4899),
const Color(0xFF8B5CF6),
const Color(0xFF14B8A6),
];
var hash = 0;
for (int i = 0; i < userId.length; i++) {
hash = (hash * 31 + userId.codeUnitAt(i)) & 0x7FFFFFFF;
}
return colors[hash % colors.length];
}
String _cursorInitialForUser(String userId) {
if (userId.isEmpty) return '?';
final first = userId[0].toUpperCase();
if (RegExp(r'[A-Z]').hasMatch(first)) return first;
return userId.length > 1 ? userId.substring(0, 2) : first;
}
Widget _buildParticipantsTrailing(
AppThemeExtension ext,
CanvasState canvasState,
) {
if (!canvasState.isConnected) return const SizedBox.shrink();
final count = canvasState.participants.length + 1;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.1),
borderRadius: AppRadius.pillBorder,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(CupertinoIcons.person_2_fill, size: 14, color: ext.accent),
const SizedBox(width: 4),
Text(
'$count',
style: AppTypography.caption1.copyWith(
color: ext.accent,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
Widget _buildSyncStatusBar(
AppThemeExtension ext,
CanvasState canvasState,
CanvasNotifier notifier,
) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: ext.successColor.withValues(alpha: 0.08),
border: Border(
bottom: BorderSide(
color: ext.successColor.withValues(alpha: 0.15),
width: 0.5,
),
),
),
child: Row(
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: ext.successColor,
),
),
const SizedBox(width: AppSpacing.sm),
Text(
'实时同步中',
style: AppTypography.caption2.copyWith(
color: ext.successColor,
fontWeight: FontWeight.w500,
),
),
const Spacer(),
if (canvasState.isSyncing)
const Padding(
padding: EdgeInsets.only(right: AppSpacing.sm),
child: CupertinoActivityIndicator(radius: 8),
)
else
GestureDetector(
onTap: () => notifier.requestSnapshotSync(),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.1),
borderRadius: AppRadius.pillBorder,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(CupertinoIcons.arrow_2_circlepath, size: 12, color: ext.accent),
const SizedBox(width: 4),
Text(
'同步画布',
style: AppTypography.caption2.copyWith(
color: ext.accent,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
const SizedBox(width: AppSpacing.sm),
Text(
'${canvasState.strokes.length} 笔画',
style: AppTypography.caption2.copyWith(color: ext.textHint),
),
],
),
);
}
void _confirmClear() {
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
title: const Text('清空画布'),
content: const Text('确定要清空所有笔画吗?此操作不可撤销。'),
actions: [
CupertinoDialogAction(
isDefaultAction: true,
onPressed: () => Navigator.pop(ctx),
child: const Text('取消'),
),
CupertinoDialogAction(
isDestructiveAction: true,
onPressed: () {
Navigator.pop(ctx);
ref.read(canvasProvider.notifier).clearCanvas();
},
child: const Text('清空'),
),
],
),
);
}
Future<void> _exportPng() async {
try {
final boundary =
_repaintBoundaryKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) return;
final image = await boundary.toImage(pixelRatio: 3.0);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) return;
if (kIsWeb) return;
final dir = await getTemporaryDirectory();
final file = File(
'${dir.path}/canvas_${DateTime.now().millisecondsSinceEpoch}.png',
);
await file.writeAsBytes(byteData.buffer.asUint8List());
if (!mounted) return;
await SharePlus.instance.share(
ShareParams(files: [XFile(file.path)], text: '🎨 闲言协作画布'),
);
Log.i('CanvasPage: exported PNG to ${file.path}');
} catch (e) {
Log.e('CanvasPage: export failed: $e');
if (!mounted) return;
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
title: const Text('导出失败'),
content: Text('无法导出画布图片: $e'),
actions: [
CupertinoDialogAction(
isDefaultAction: true,
onPressed: () => Navigator.pop(ctx),
child: const Text('确定'),
),
],
),
);
}
}
}
class _RemoteCursorWidget extends StatelessWidget {
const _RemoteCursorWidget({
required this.color,
required this.initial,
});
final Color color;
final String initial;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: CupertinoColors.white, width: 2),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.4),
blurRadius: 6,
spreadRadius: 1,
),
],
),
alignment: Alignment.center,
child: Text(
initial,
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 10,
fontWeight: FontWeight.w700,
height: 1.0,
),
textAlign: TextAlign.center,
),
),
CustomPaint(
size: const Size(8, 8),
painter: _CursorPointerPainter(color: color),
),
],
);
}
}
class _CursorPointerPainter extends CustomPainter {
const _CursorPointerPainter({required this.color});
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill
..isAntiAlias = true;
final path = Path()
..moveTo(0, 0)
..lineTo(size.width, size.height * 0.6)
..lineTo(size.width * 0.4, size.height * 0.5)
..close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant _CursorPointerPainter oldDelegate) {
return oldDelegate.color != color;
}
}