Files
xianyan/lib/features/daily_card/presentation/daily_card_ar_view.dart
2026-06-07 18:20:26 +08:00

732 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 闲言APP — 日签卡片AR 3D展示页面
/// 创建时间: 2026-06-04
/// 更新时间: 2026-06-04
/// 作用: 使用设备传感器+3D变换模拟AR空间中悬浮的日签卡片
/// 上次更新: 初始创建伪AR效果陀螺仪+透视+景深+光影)
/// ============================================================
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/rendering.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:share_plus/share_plus.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/utils/logger.dart';
import '../../../../shared/widgets/containers/glass_container.dart';
import '../../../../shared/widgets/feedback/app_toast.dart';
import '../../../../shared/widgets/adaptive/adaptive_back_button.dart';
import '../models/daily_card_models.dart';
import 'widgets/card_renderer.dart';
// ============================================================
// AR视图入口参数
// ============================================================
/// AR视图所需的卡片数据封装
class DailyCardArParams {
const DailyCardArParams({
required this.content,
required this.style,
required this.date,
required this.weekday,
});
final DailyCardContent content;
final CardStylePreset style;
final String date;
final String weekday;
}
// ============================================================
// AR主题预设 — 6套AR空间氛围主题
// ============================================================
enum ArTheme {
cosmic('🌌', '宇宙深空'),
aurora('🌈', '极光幻境'),
sunset('🌅', '落日余晖'),
forest('🌿', '森林秘境'),
ocean('🌊', '深海探幽'),
crystal('💎', '水晶殿堂');
const ArTheme(this.emoji, this.label);
final String emoji;
final String label;
List<Color> get colors => switch (this) {
ArTheme.cosmic => [
const Color(0xFF0a0a2e),
const Color(0xFF1a1a4e),
const Color(0xFF0d0d3b),
],
ArTheme.aurora => [
const Color(0xFF0a2a3a),
const Color(0xFF1a4a5a),
const Color(0xFF0d3d4d),
],
ArTheme.sunset => [
const Color(0xFF2d1b30),
const Color(0xFF4a2535),
const Color(0xFF351820),
],
ArTheme.forest => [
const Color(0xFF0a1f15),
const Color(0xFF153022),
const Color(0xFF0d2819),
],
ArTheme.ocean => [
const Color(0xFF051525),
const Color(0xFF0a2540),
const Color(0xFF071d32),
],
ArTheme.crystal => [
const Color(0xFF1a1a2e),
const Color(0xFF16213e),
const Color(0xFF121230),
],
};
}
// ============================================================
// 页面主体
// ============================================================
class DailyCardArView extends ConsumerStatefulWidget {
const DailyCardArView({
super.key,
required this.params,
});
final DailyCardArParams params;
@override
ConsumerState<DailyCardArView> createState() => _DailyCardArViewState();
}
class _DailyCardArViewState extends ConsumerState<DailyCardArView>
with TickerProviderStateMixin {
// ---- 传感器数据 ----
double _tiltX = 0.0; // X轴倾斜俯仰
double _tiltY = 0.0; // Y轴倾斜偏航
StreamSubscription<AccelerometerEvent>? _accelSubscription;
bool _sensorAvailable = false;
// ---- 手势控制 ----
double _rotateX = 0.0;
double _rotateY = 0.0;
double _scale = 1.0;
Offset? _lastPanPosition;
// ---- 动画控制器 ----
late AnimationController _lightController;
late AnimationController _floatController;
late AnimationController _particleController;
// ---- 状态 ----
ArTheme _currentTheme = ArTheme.cosmic;
bool _showControls = true;
bool _autoRotate = true;
final GlobalKey _repaintKey = GlobalKey();
// ---- 常量 ----
static const double _maxTilt = 0.5; // 最大倾斜弧度(~28度)
static const double _perspective = 0.003; // 透视强度
static const double _maxGestureAngle = 0.8;
@override
void initState() {
super.initState();
_initSensors();
_lightController = AnimationController(
vsync: this,
duration: const Duration(seconds: 4),
)..repeat();
_floatController = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
)..repeat(reverse: true);
_particleController = AnimationController(
vsync: this,
duration: const Duration(seconds: 8),
)..repeat();
}
@override
void dispose() {
_accelSubscription?.cancel();
_lightController.dispose();
_floatController.dispose();
_particleController.dispose();
super.dispose();
}
// ---- 传感器初始化 ----
void _initSensors() {
try {
_accelSubscription =
accelerometerEventStream(samplingPeriod: const Duration(milliseconds: 60))
.listen(_onAccelerometerData, onError: (e) {
Log.w('AR视图: 加速度传感器不可用,切换到纯动画模式');
_sensorAvailable = false;
});
_sensorAvailable = true;
Log.i('AR视图: 加速度传感器已启动');
} catch (e) {
Log.w('AR视图: 传感器初始化失败: $e');
_sensorAvailable = false;
}
}
void _onAccelerometerData(AccelerometerEvent event) {
if (!mounted || !_autoRotate) return;
// 将加速度计数据映射到倾斜角度 (归一化到[-1, 1]再乘以最大倾斜)
final rawX = (event.x / 10.0).clamp(-1.0, 1.0);
final rawY = (event.y / 10.0).clamp(-1.0, 1.0);
setState(() {
_tiltX = rawX * _maxTilt;
_tiltY = rawY * _maxTilt;
});
}
// ---- 手势处理 ----
void _onScaleStart(ScaleStartDetails details) {
_lastPanPosition = details.localFocalPoint;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
// 缩放处理
setState(() {
_scale = (_scale * details.scale).clamp(0.6, 2.0);
});
// 旋转处理(原 pan 逻辑)
if (_lastPanPosition == null) return;
final delta = details.localFocalPoint - _lastPanPosition!;
final size = MediaQuery.of(context).size;
setState(() {
_rotateY += (delta.dx / size.width) * _maxGestureAngle;
_rotateX -= (delta.dy / size.height) * _maxGestureAngle;
_rotateX = _rotateX.clamp(-_maxGestureAngle, _maxGestureAngle);
_rotateY = _rotateY.clamp(-_maxGestureAngle, _maxGestureAngle);
});
_lastPanPosition = details.localFocalPoint;
}
void _onScaleEnd(ScaleEndDetails details) {
_lastPanPosition = null;
}
void _onDoubleTap() {
HapticFeedback.lightImpact();
setState(() {
_rotateX = 0.0;
_rotateY = 0.0;
_scale = 1.0;
_tiltX = 0.0;
_tiltY = 0.0;
});
}
// ---- 截图功能 ----
Future<void> _captureAndShare() async {
HapticFeedback.mediumImpact();
try {
final boundary = _repaintKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
if (boundary == null) {
AppToast.show('❌ 截图失败');
return;
}
final image = await boundary.toImage(pixelRatio: 3.0);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) {
AppToast.show('❌ 截图失败');
return;
}
final bytes = byteData.buffer.asUint8List();
final tempDir = Directory.systemTemp;
final file = File(
'${tempDir.path}/xianyan_ar_${DateTime.now().millisecondsSinceEpoch}.png',
);
await file.writeAsBytes(bytes);
await SharePlus.instance.share(
ShareParams(files: [XFile(file.path)], text: '🎴 闲言 · AR日签卡片'),
);
Log.i('AR视图: 截图分享成功');
} catch (e) {
Log.e('AR视图: 截图失败', e);
AppToast.show('❌ 分享失败');
}
}
// ---- 主题切换 ----
void _cycleTheme() {
HapticFeedback.selectionClick();
const themes = ArTheme.values;
final idx = (themes.indexOf(_currentTheme) + 1) % themes.length;
setState(() => _currentTheme = themes[idx]);
}
// ---- UI构建 ----
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
leading: const AdaptiveBackButton(),
middle: Text(
'${_currentTheme.emoji} AR 日签',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: ext.textPrimary,
),
),
trailing: GestureDetector(
onTap: () => setState(() => _showControls = !_showControls),
child: Icon(
_showControls ? CupertinoIcons.eye_slash : CupertinoIcons.eye,
color: ext.textSecondary,
size: 20,
),
),
backgroundColor: ext.bgPrimary.withValues(alpha: 0.85),
border: null,
),
child: SafeArea(
child: Stack(
children: [
// 深色背景层
Positioned.fill(child: _buildBackground(ext)),
// AR内容层
Positioned.fill(child: _buildArContent()),
// 控制面板
if (_showControls)
Positioned(
left: AppSpacing.md,
right: AppSpacing.md,
bottom: AppSpacing.lg + MediaQuery.of(context).padding.bottom,
child: _buildControlPanel(ext),
),
],
),
),
);
}
// ============================================================
// AR空间背景 — 渐变+粒子+景深模糊
// ============================================================
Widget _buildBackground(AppThemeExtension ext) {
return AnimatedBuilder(
animation: _particleController,
builder: (context, child) {
final t = _particleController.value;
return Container(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment(
0.3 + math.sin(t * math.pi * 2) * 0.2,
-0.3 + math.cos(t * math.pi * 2) * 0.15,
),
radius: 1.4,
colors: _currentTheme.colors,
),
),
child: Stack(
children: [
// 星空粒子层
...List.generate(20, (i) => _buildParticle(i, t)),
// 底部环境光晕
Positioned(
bottom: -100,
left: -100,
right: -100,
child: Container(
height: 300,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _currentTheme.colors[1].withValues(alpha: 0.3),
),
),
),
],
),
);
},
);
}
Widget _buildParticle(int index, double t) {
final seed = index * 137.508; // 黄金角度分布
final baseX = math.sin(seed) * 0.5 + 0.5;
final baseY = math.cos(seed * 1.3) * 0.5 + 0.5;
final phase = (t + index * 0.1) % 1.0;
final opacity = (math.sin(phase * math.pi * 2) * 0.3 + 0.2).clamp(0.0, 1.0);
final size = 1.0 + index % 3;
return Positioned(
left: baseX * MediaQuery.of(context).size.width,
top: baseY * MediaQuery.of(context).size.height,
child: AnimatedOpacity(
opacity: opacity,
duration: const Duration(milliseconds: 200),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: opacity * 0.6),
),
),
),
);
}
// ============================================================
// AR核心内容 — 3D悬浮卡片
// ============================================================
Widget _buildArContent() {
// 合成最终旋转角度:传感器倾斜 + 手势旋转
final totalRotateX = _tiltX + _rotateX;
final totalRotateY = _tiltY + _rotateY;
return GestureDetector(
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
onScaleEnd: _onScaleEnd,
onDoubleTap: _onDoubleTap,
behavior: HitTestBehavior.opaque,
child: AnimatedBuilder(
animation: Listenable.merge([
_lightController,
_floatController,
]),
builder: (context, _) {
final floatOffset = _floatController.value * 8.0;
final lightPhase = _lightController.value * math.pi * 2;
return Center(
child: Transform(
alignment: FractionalOffset.center,
transform: Matrix4.identity()
..setEntry(3, 2, _perspective)
..rotateX(totalRotateX)
..rotateY(totalRotateY),
child: Transform.translate(
offset: Offset(0, floatOffset),
child: Transform.scale(
scale: _scale,
child: RepaintBoundary(
key: _repaintKey,
child: _build3DCard(lightPhase),
),
),
),
),
);
},
),
);
}
// ============================================================
// 3D卡片 — 阴影+光影+内容+景深边框
// ============================================================
Widget _build3DCard(double lightPhase) {
final cardWidth = MediaQuery.of(context).size.width * 0.82;
final radius = AppRadius.of(context);
return Container(
width: cardWidth,
constraints: const BoxConstraints(minHeight: 380, maxHeight: 520),
child: Stack(
children: [
// 卡片阴影(跟随倾斜动态变化)
Positioned.fill(
child: Transform(
transform: Matrix4.identity()
..translateByDouble(0.0, 12.0, 0.0, 1.0)
..scaleByDouble(0.95, 0.95, 0.95, 1.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius.xl),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
blurRadius: 40,
offset: const Offset(0, 20),
spreadRadius: 4,
),
],
),
),
),
),
// 主卡片容器
ClipRRect(
borderRadius: BorderRadius.circular(radius.xl),
child: Stack(
fit: StackFit.expand,
children: [
// 卡片内容
CardRenderer(
key: ValueKey(widget.params.style.id),
content: widget.params.content,
style: widget.params.style,
date: widget.params.date,
weekday: widget.params.weekday,
),
// 光影流动覆盖层
_buildLightOverlay(lightPhase, radius),
// 边缘高光(模拟玻璃反光)
_buildEdgeHighlight(radius),
// 景深前景模糊边缘
_buildDepthVignette(),
],
),
),
],
),
);
}
// ---- 光影流动效果 ----
Widget _buildLightOverlay(double lightPhase, AppRadiusData radius) {
return IgnorePointer(
child: ShaderMask(
shaderCallback: (bounds) => LinearGradient(
begin: Alignment(
math.sin(lightPhase) * 1.5,
math.cos(lightPhase) * 1.5 - 0.5,
),
end: Alignment(
math.sin(lightPhase + math.pi) * 1.5,
math.cos(lightPhase + math.pi) * 1.5 + 0.5,
),
colors: [
Colors.white.withValues(alpha: 0.08),
Colors.transparent,
Colors.white.withValues(alpha: 0.04),
Colors.transparent,
],
stops: const [0.0, 0.4, 0.6, 1.0],
).createShader(bounds),
blendMode: BlendMode.overlay,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius.xl),
),
),
),
);
}
// ---- 边缘高光 ----
Widget _buildEdgeHighlight(AppRadiusData radius) {
return IgnorePointer(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius.xl),
border: Border.all(
color: Colors.white.withValues(alpha: 0.12),
),
),
),
);
}
// ---- 景深暗角 ----
Widget _buildDepthVignette() {
return IgnorePointer(
child: ShaderMask(
shaderCallback: (bounds) => RadialGradient(
radius: 0.8,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.15),
],
).createShader(bounds),
blendMode: BlendMode.multiply,
child: Container(color: Colors.transparent),
),
);
}
// ============================================================
// 底部控制面板 — Cupertino风格
// ============================================================
Widget _buildControlPanel(AppThemeExtension ext) {
final radius = AppRadius.of(context);
return GlassContainer(
depth: GlassDepth.elevated,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
_buildCtrlButton(
icon: CupertinoIcons.photo_camera,
label: '截图',
ext: ext,
radius: radius,
onTap: _captureAndShare,
),
const SizedBox(width: AppSpacing.sm),
_buildCtrlButton(
icon: CupertinoIcons.photo,
label: _currentTheme.label,
ext: ext,
radius: radius,
onTap: _cycleTheme,
),
const SizedBox(width: AppSpacing.sm),
_buildToggleBtn(
active: _autoRotate,
iconOn: CupertinoIcons.arrow_2_circlepath,
iconOff: CupertinoIcons.hand_draw,
label: _sensorAvailable ? '自动' : '手动',
ext: ext,
radius: radius,
onTap: () {
HapticFeedback.selectionClick();
setState(() => _autoRotate = !_autoRotate);
},
),
const Spacer(),
_buildCtrlButton(
icon: CupertinoIcons.arrow_counterclockwise,
label: '重置',
ext: ext,
radius: radius,
onTap: _onDoubleTap,
),
],
),
// 提示文字
Padding(
padding: const EdgeInsets.only(top: AppSpacing.xs),
child: Text(
'拖拽旋转 · 双击重置 · 捏合缩放',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: ext.textHint,
letterSpacing: 0.5,
),
),
),
],
),
);
}
Widget _buildCtrlButton({
required IconData icon,
required String label,
required AppThemeExtension ext,
required AppRadiusData radius,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: ext.bgSecondary.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(radius.md),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: ext.iconSecondary),
const SizedBox(width: 4),
Text(label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: ext.textSecondary,
)),
],
),
),
);
}
Widget _buildToggleBtn({
required bool active,
required IconData iconOn,
required IconData iconOff,
required String label,
required AppThemeExtension ext,
required AppRadiusData radius,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: active ? ext.accent.withValues(alpha: 0.2) : ext.bgSecondary.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(radius.md),
border: active
? Border.all(color: ext.accent.withValues(alpha: 0.4), width: 0.5)
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
active ? iconOn : iconOff,
size: 14,
color: active ? ext.accent : ext.iconSecondary,
),
const SizedBox(width: 4),
Text(label,
style: TextStyle(
fontSize: 12,
fontWeight: active ? FontWeight.w600 : FontWeight.w500,
color: active ? ext.accent : ext.textSecondary,
)),
],
),
),
);
}
}