604 lines
18 KiB
Dart
604 lines
18 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/services.dart';
|
|
// 注意:运行前需要在 pubspec.yaml 添加 image_gallery_saver 和 permission_handler
|
|
import 'package:image_gallery_saver/image_gallery_saver.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
|
|
import '../../core/app_ads_tools.dart';
|
|
|
|
/// 粒子效果类型枚举
|
|
enum EffectType {
|
|
fireflies, // 萤火虫/漂浮粒子
|
|
geometric, // 几何连线
|
|
snow, // 飘雪
|
|
galaxy, // 旋转星系
|
|
}
|
|
|
|
class ParticleHomePage extends StatefulWidget {
|
|
const ParticleHomePage({super.key});
|
|
|
|
@override
|
|
State<ParticleHomePage> createState() => _ParticleHomePageState();
|
|
}
|
|
|
|
class _ParticleHomePageState extends State<ParticleHomePage>
|
|
with SingleTickerProviderStateMixin {
|
|
// 用于动画循环的控制器
|
|
late AnimationController _controller;
|
|
// 粒子列表
|
|
final List<Particle> _particles = [];
|
|
// 随机数生成器
|
|
final Random _random = Random();
|
|
// 当前选择的效果
|
|
EffectType _currentEffect = EffectType.fireflies;
|
|
// 用于截屏的 Key
|
|
final GlobalKey _repaintKey = GlobalKey();
|
|
// 屏幕尺寸缓存
|
|
Size _screenSize = Size.zero;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// 初始化动画控制器,无限循环
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(seconds: 1),
|
|
)..repeat(); // 驱动界面刷新
|
|
|
|
// 监听动画帧,更新粒子状态
|
|
_controller.addListener(_updateParticles);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// 初始化或重置粒子系统
|
|
void _initParticles(Size size) {
|
|
_particles.clear();
|
|
int count = 0;
|
|
|
|
// 根据不同效果设置粒子数量
|
|
switch (_currentEffect) {
|
|
case EffectType.fireflies:
|
|
count = 100;
|
|
break;
|
|
case EffectType.geometric:
|
|
count = 60;
|
|
break;
|
|
case EffectType.snow:
|
|
count = 150;
|
|
break;
|
|
case EffectType.galaxy:
|
|
count = 200;
|
|
break;
|
|
}
|
|
|
|
for (int i = 0; i < count; i++) {
|
|
_particles.add(Particle.random(size, _random, _currentEffect));
|
|
}
|
|
}
|
|
|
|
/// 每一帧更新粒子位置
|
|
void _updateParticles() {
|
|
if (_screenSize == Size.zero) return;
|
|
|
|
for (var particle in _particles) {
|
|
particle.update(_screenSize, _currentEffect);
|
|
}
|
|
// 触发重绘
|
|
setState(() {});
|
|
}
|
|
|
|
/// 切换效果
|
|
void _switchEffect() {
|
|
setState(() {
|
|
int nextIndex = (_currentEffect.index + 1) % EffectType.values.length;
|
|
_currentEffect = EffectType.values[nextIndex];
|
|
_initParticles(_screenSize);
|
|
});
|
|
}
|
|
|
|
/// 保存截图到相册
|
|
Future<void> _saveToGallery() async {
|
|
try {
|
|
// 1. 请求存储权限
|
|
var status = await Permission.storage.request();
|
|
// Android 13+ 可能需要 photos 权限,这里做简单处理,实际需更严谨判断
|
|
if (status.isDenied) {
|
|
status = await Permission.photos.request();
|
|
}
|
|
|
|
if (status.isGranted ||
|
|
await Permission.storage.isGranted ||
|
|
await Permission.photos.isGranted) {
|
|
// 2. 获取 RenderRepaintBoundary
|
|
RenderRepaintBoundary? boundary =
|
|
_repaintKey.currentContext?.findRenderObject()
|
|
as RenderRepaintBoundary?;
|
|
|
|
if (boundary == null) return;
|
|
|
|
// 3. 转换为图片数据 (高像素密度,保证壁纸清晰度)
|
|
ui.Image image = await boundary.toImage(pixelRatio: 3.0);
|
|
ByteData? byteData = await image.toByteData(
|
|
format: ui.ImageByteFormat.png,
|
|
);
|
|
|
|
if (byteData != null) {
|
|
final result = await ImageGallerySaver.saveImage(
|
|
byteData.buffer.asUint8List(),
|
|
quality: 100,
|
|
name: "particle_wallpaper_${DateTime.now().millisecondsSinceEpoch}",
|
|
);
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
result['isSuccess'] ? 'Saved to Gallery!' : 'Failed to save.',
|
|
),
|
|
backgroundColor: result['isSuccess']
|
|
? Colors.green
|
|
: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('Permission denied.')));
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugPrint(e.toString());
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// 获取屏幕尺寸并在第一次时初始化粒子
|
|
final size = MediaQuery.of(context).size;
|
|
if (_screenSize != size) {
|
|
_screenSize = size;
|
|
_initParticles(size);
|
|
}
|
|
|
|
return Scaffold(
|
|
body: Stack(
|
|
children: [
|
|
// 1. 绘图层 (被 RepaintBoundary 包裹以用于截图)
|
|
RepaintBoundary(
|
|
key: _repaintKey,
|
|
child: Container(
|
|
color: Colors.black, // 背景色
|
|
width: double.infinity,
|
|
height: double.infinity,
|
|
child: CustomPaint(
|
|
painter: ParticlePainter(
|
|
particles: _particles,
|
|
effect: _currentEffect,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// 2. 左上角返回按钮
|
|
Positioned(
|
|
top: MediaQuery.of(context).padding.top + 8,
|
|
left: 8,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
Colors.purple.withValues(alpha: 0.3),
|
|
Colors.blue.withValues(alpha: 0.3),
|
|
],
|
|
),
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.purple.withValues(alpha: 0.3),
|
|
blurRadius: 8,
|
|
spreadRadius: 1,
|
|
),
|
|
],
|
|
),
|
|
child: IconButton(
|
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
|
onPressed: () => Navigator.pop(context),
|
|
tooltip: 'Back',
|
|
),
|
|
),
|
|
),
|
|
|
|
// 顶部标题卡片
|
|
Positioned(
|
|
top: MediaQuery.of(context).padding.top + 16,
|
|
left: 0,
|
|
right: 0,
|
|
child: Center(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 24,
|
|
vertical: 12,
|
|
),
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
Colors.purple.withValues(alpha: 0.3),
|
|
Colors.blue.withValues(alpha: 0.3),
|
|
],
|
|
),
|
|
borderRadius: BorderRadius.circular(30),
|
|
border: Border.all(
|
|
color: Colors.white.withValues(alpha: 0.2),
|
|
width: 1,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.purple.withValues(alpha: 0.2),
|
|
blurRadius: 20,
|
|
spreadRadius: 2,
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.2),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.auto_awesome,
|
|
color: Colors.white,
|
|
size: 20,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
const Text(
|
|
'AI Generator',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
letterSpacing: 0.5,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// 3. UI 控制层 (半透明,不影响视觉)
|
|
Positioned(
|
|
bottom: 40,
|
|
left: 16,
|
|
right: 16,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// 效果选择器
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
Colors.purple.withValues(alpha: 0.3),
|
|
Colors.blue.withValues(alpha: 0.3),
|
|
],
|
|
),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
color: Colors.white.withValues(alpha: 0.2),
|
|
width: 1,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.purple.withValues(alpha: 0.3),
|
|
blurRadius: 20,
|
|
spreadRadius: 2,
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.palette,
|
|
color: Colors.white,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Effect: ${_currentEffect.name.toUpperCase()}',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildActionButton(
|
|
icon: Icons.refresh,
|
|
label: 'Switch Effect',
|
|
onTap: _switchEffect,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _buildActionButton(
|
|
icon: Icons.download,
|
|
label: 'Save',
|
|
onTap: ()async{
|
|
final bool adShown = await AppAdsTools.instance.showAd(
|
|
AdPlacement.interstitial3,
|
|
onAdClosed: () {
|
|
_saveToGallery();
|
|
},
|
|
);
|
|
if (!adShown) {
|
|
_saveToGallery();
|
|
}
|
|
},
|
|
isPrimary: true,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildActionButton({
|
|
required IconData icon,
|
|
required String label,
|
|
required VoidCallback onTap,
|
|
bool isPrimary = false,
|
|
}) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
gradient: isPrimary
|
|
? LinearGradient(
|
|
colors: [Colors.purple.shade400, Colors.blue.shade400],
|
|
)
|
|
: null,
|
|
color: isPrimary ? null : Colors.white.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: isPrimary
|
|
? Colors.white.withValues(alpha: 0.3)
|
|
: Colors.white.withValues(alpha: 0.2),
|
|
width: 1,
|
|
),
|
|
boxShadow: isPrimary
|
|
? [
|
|
BoxShadow(
|
|
color: Colors.purple.withValues(alpha: 0.4),
|
|
blurRadius: 12,
|
|
spreadRadius: 1,
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, color: Colors.white, size: 20),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
label,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 13,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 粒子数据模型
|
|
class Particle {
|
|
double x;
|
|
double y;
|
|
double vx; // X轴速度
|
|
double vy; // Y轴速度
|
|
double size;
|
|
Color color;
|
|
double life; // 生命周期 (0.0 - 1.0)
|
|
double angle; // 用于旋转效果
|
|
|
|
Particle({
|
|
required this.x,
|
|
required this.y,
|
|
required this.vx,
|
|
required this.vy,
|
|
required this.size,
|
|
required this.color,
|
|
this.life = 1.0,
|
|
this.angle = 0.0,
|
|
});
|
|
|
|
/// 生成随机粒子
|
|
factory Particle.random(Size screenSize, Random random, EffectType type) {
|
|
Color randomColor() {
|
|
final colors = [
|
|
Colors.cyanAccent,
|
|
Colors.purpleAccent,
|
|
Colors.blueAccent,
|
|
Colors.pinkAccent,
|
|
Colors.tealAccent,
|
|
];
|
|
return colors[random.nextInt(colors.length)];
|
|
}
|
|
|
|
double x = random.nextDouble() * screenSize.width;
|
|
double y = random.nextDouble() * screenSize.height;
|
|
double vx = (random.nextDouble() - 0.5) * 2;
|
|
double vy = (random.nextDouble() - 0.5) * 2;
|
|
double size = random.nextDouble() * 3 + 1;
|
|
Color color = randomColor().withValues(
|
|
alpha: random.nextDouble() * 0.5 + 0.3,
|
|
);
|
|
|
|
if (type == EffectType.snow) {
|
|
vy = random.nextDouble() * 2 + 1; // 向下落
|
|
vx = (random.nextDouble() - 0.5) * 0.5; // 轻微左右飘
|
|
color = Colors.white.withValues(alpha: random.nextDouble() * 0.8 + 0.2);
|
|
} else if (type == EffectType.galaxy) {
|
|
// 这里的 x, y 会在 update 中根据中心点重新计算,只需初始化角度
|
|
x = screenSize.width / 2;
|
|
y = screenSize.height / 2;
|
|
}
|
|
|
|
return Particle(
|
|
x: x,
|
|
y: y,
|
|
vx: vx,
|
|
vy: vy,
|
|
size: size,
|
|
color: color,
|
|
angle: random.nextDouble() * pi * 2,
|
|
life: random.nextDouble(),
|
|
);
|
|
}
|
|
|
|
/// 更新粒子位置和状态
|
|
void update(Size size, EffectType type) {
|
|
if (type == EffectType.galaxy) {
|
|
// 星系模式:围绕中心旋转
|
|
double centerX = size.width / 2;
|
|
double centerY = size.height / 2;
|
|
angle += 0.01 * (vx.sign == 0 ? 1 : vx.sign); // 旋转速度
|
|
double radius = this.size * 30 + (life * 150); // 半径
|
|
x = centerX + cos(angle) * radius;
|
|
y = centerY + sin(angle) * radius;
|
|
return;
|
|
}
|
|
|
|
x += vx;
|
|
y += vy;
|
|
|
|
// 边界检测:超出屏幕则反弹或重置
|
|
if (type == EffectType.snow) {
|
|
if (y > size.height) {
|
|
y = -10;
|
|
x = Random().nextDouble() * size.width;
|
|
}
|
|
} else {
|
|
if (x < 0 || x > size.width) vx = -vx;
|
|
if (y < 0 || y > size.height) vy = -vy;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 核心绘制逻辑
|
|
class ParticlePainter extends CustomPainter {
|
|
final List<Particle> particles;
|
|
final EffectType effect;
|
|
|
|
ParticlePainter({required this.particles, required this.effect});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()..strokeCap = StrokeCap.round;
|
|
|
|
for (var i = 0; i < particles.length; i++) {
|
|
var p = particles[i];
|
|
|
|
// 1. 绘制粒子本体
|
|
paint.color = p.color;
|
|
paint.strokeWidth = p.size;
|
|
|
|
// 不同的绘制形状
|
|
if (effect == EffectType.snow) {
|
|
// 雪花带一点模糊
|
|
paint.maskFilter = const MaskFilter.blur(BlurStyle.normal, 2);
|
|
canvas.drawCircle(Offset(p.x, p.y), p.size, paint);
|
|
} else {
|
|
canvas.drawCircle(Offset(p.x, p.y), p.size / 2, paint);
|
|
}
|
|
|
|
// 2. 几何模式下的连线效果
|
|
if (effect == EffectType.geometric) {
|
|
_drawConnections(canvas, p, i, size);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 绘制连线:如果两个粒子距离够近,就画一条线
|
|
void _drawConnections(
|
|
Canvas canvas,
|
|
Particle p1,
|
|
int currentIndex,
|
|
Size size,
|
|
) {
|
|
Paint linePaint = Paint()..strokeWidth = 1.0;
|
|
double connectDistance = 100.0; // 连线阈值
|
|
|
|
for (var j = currentIndex + 1; j < particles.length; j++) {
|
|
var p2 = particles[j];
|
|
double dx = p1.x - p2.x;
|
|
double dy = p1.y - p2.y;
|
|
double dist = sqrt(dx * dx + dy * dy);
|
|
|
|
if (dist < connectDistance) {
|
|
// 距离越近,线条越不透明
|
|
double opacity = 1.0 - (dist / connectDistance);
|
|
linePaint.color = Colors.cyanAccent.withValues(alpha: opacity * 0.5);
|
|
canvas.drawLine(Offset(p1.x, p1.y), Offset(p2.x, p2.y), linePaint);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) {
|
|
return true; // 总是重绘以实现动画
|
|
}
|
|
}
|