- 清理大量废弃的 barrel 导出文件,移除冗余的中间导出层 - 修复所有相对路径导入错误,统一调整为扁平化模块引用 - 更新多平台 pubspec 版本号与依赖库版本 - 补充后端功能问题管理后台与脚本工具 - 调整部分页面的快捷方式文案适配新功能 - 更新部分翻译覆盖率与API文档
472 lines
14 KiB
Dart
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;
|
|
}
|
|
}
|