1. 添加编辑器3D场景服务接口及实现 2. 实现平台IO工具类与数据库连接 3. 新增SVG图标资源 4. 优化编辑器操作Mixin架构 5. 修复Web端兼容性问题 6. 更新依赖配置与构建脚本 7. 改进主题自适应服务 8. 添加对齐辅助线组件 9. 完善迷你文字编辑栏 10. 优化页面过渡动画
473 lines
13 KiB
Dart
473 lines
13 KiB
Dart
// ============================================================
|
|
// 闲言APP — 渐变自定义编辑器
|
|
// 创建时间: 2026-04-24
|
|
// 更新时间: 2026-04-24
|
|
// 作用: 多节点渐变编辑 + 角度旋转 + 实时预览
|
|
// 上次更新: 初始创建
|
|
// ============================================================
|
|
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:xianyan/core/theme/app_colors.dart';
|
|
import 'package:xianyan/editor/widgets/editors/color_picker.dart';
|
|
|
|
/// 渐变节点
|
|
class GradientStop {
|
|
const GradientStop({required this.color, required this.position});
|
|
|
|
final Color color;
|
|
final double position;
|
|
|
|
GradientStop copyWith({Color? color, double? position}) {
|
|
return GradientStop(
|
|
color: color ?? this.color,
|
|
position: position ?? this.position,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 渐变编辑器
|
|
class GradientEditor extends StatefulWidget {
|
|
const GradientEditor({
|
|
super.key,
|
|
required this.colors,
|
|
required this.stops,
|
|
required this.angle,
|
|
required this.onChanged,
|
|
required this.onAngleChanged,
|
|
this.onDone,
|
|
});
|
|
|
|
final List<Color> colors;
|
|
final List<double> stops;
|
|
final double angle;
|
|
final ValueChanged<List<GradientStop>> onChanged;
|
|
final ValueChanged<double> onAngleChanged;
|
|
final VoidCallback? onDone;
|
|
|
|
@override
|
|
State<GradientEditor> createState() => _GradientEditorState();
|
|
}
|
|
|
|
class _GradientEditorState extends State<GradientEditor> {
|
|
late List<GradientStop> _stops;
|
|
late double _angle;
|
|
int? _selectedStopIndex;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_angle = widget.angle;
|
|
_stops = List.generate(
|
|
widget.colors.length,
|
|
(i) => GradientStop(
|
|
color: widget.colors[i],
|
|
position: i < widget.stops.length
|
|
? widget.stops[i]
|
|
: i / (widget.colors.length - 1).clamp(1, 100),
|
|
),
|
|
);
|
|
if (_stops.isEmpty) {
|
|
_stops = [
|
|
const GradientStop(color: Color(0xFF6C63FF), position: 0.0),
|
|
const GradientStop(color: Color(0xFF4ECDC4), position: 1.0),
|
|
];
|
|
}
|
|
_selectedStopIndex = 0;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: const BoxDecoration(
|
|
color: Color(0xFFF2F2F7),
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_buildHandle(),
|
|
const SizedBox(height: 12),
|
|
_buildHeader(),
|
|
const SizedBox(height: 16),
|
|
_buildPreview(),
|
|
const SizedBox(height: 16),
|
|
_buildGradientBar(),
|
|
const SizedBox(height: 16),
|
|
_buildAngleControl(),
|
|
const SizedBox(height: 16),
|
|
if (_selectedStopIndex != null) _buildColorEditor(),
|
|
const SizedBox(height: 16),
|
|
_buildActionButtons(),
|
|
SizedBox(height: MediaQuery.paddingOf(context).bottom + 8),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHandle() {
|
|
return Center(
|
|
child: Container(
|
|
width: 36,
|
|
height: 5,
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader() {
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
CupertinoButton(
|
|
padding: EdgeInsets.zero,
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text(
|
|
'取消',
|
|
style: TextStyle(fontSize: 17, color: CupertinoColors.systemBlue),
|
|
),
|
|
),
|
|
const Text(
|
|
'🎨 渐变编辑器',
|
|
style: TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w600,
|
|
color: Color(0xFF1A1A2E),
|
|
),
|
|
),
|
|
CupertinoButton(
|
|
padding: EdgeInsets.zero,
|
|
onPressed: () {
|
|
_notifyChanged();
|
|
widget.onDone?.call();
|
|
Navigator.pop(context);
|
|
},
|
|
child: const Text(
|
|
'完成',
|
|
style: TextStyle(
|
|
fontSize: 17,
|
|
color: CupertinoColors.systemBlue,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildPreview() {
|
|
return Container(
|
|
height: 120,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
gradient: _buildGradient(),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.5)),
|
|
boxShadow: [
|
|
BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 12),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
LinearGradient _buildGradient() {
|
|
final rad = _angle * pi / 180;
|
|
final begin = Alignment(sin(rad), -cos(rad));
|
|
final end = Alignment(-sin(rad), cos(rad));
|
|
|
|
final sortedStops = List<GradientStop>.from(_stops)
|
|
..sort((a, b) => a.position.compareTo(b.position));
|
|
|
|
return LinearGradient(
|
|
begin: begin,
|
|
end: end,
|
|
colors: sortedStops.map((s) => s.color).toList(),
|
|
stops: sortedStops.map((s) => s.position).toList(),
|
|
);
|
|
}
|
|
|
|
Widget _buildGradientBar() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'色标节点',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.black.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
GestureDetector(
|
|
onHorizontalDragUpdate: (details) {
|
|
if (_selectedStopIndex == null) return;
|
|
final box = context.findRenderObject() as RenderBox;
|
|
final barWidth = box.size.width - 32;
|
|
final pos = (details.localPosition.dx - 16) / barWidth * 100 / 100;
|
|
final clamped = pos.clamp(0.0, 1.0);
|
|
|
|
setState(() {
|
|
_stops[_selectedStopIndex!] = _stops[_selectedStopIndex!]
|
|
.copyWith(position: clamped);
|
|
});
|
|
_notifyChanged();
|
|
},
|
|
child: Container(
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
gradient: _buildGradient(),
|
|
border: Border.all(color: Colors.black.withValues(alpha: 0.1)),
|
|
),
|
|
child: Stack(
|
|
children: _stops.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final stop = entry.value;
|
|
final isSelected = index == _selectedStopIndex;
|
|
|
|
return Positioned(
|
|
left:
|
|
stop.position * (MediaQuery.of(context).size.width - 64) +
|
|
16 -
|
|
12,
|
|
top: isSelected ? 4 : 10,
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
setState(() => _selectedStopIndex = index);
|
|
},
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
width: 24,
|
|
height: isSelected ? 40 : 28,
|
|
decoration: BoxDecoration(
|
|
color: stop.color,
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(
|
|
color: isSelected
|
|
? LightColors.primary
|
|
: Colors.white,
|
|
width: isSelected ? 2.5 : 1.5,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.2),
|
|
blurRadius: 4,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildAngleControl() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'角度 ${_angle.round()}°',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.black.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: CupertinoSlider(
|
|
value: _angle,
|
|
max: 360,
|
|
onChanged: (v) {
|
|
setState(() => _angle = v);
|
|
widget.onAngleChanged(v);
|
|
},
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 44,
|
|
height: 44,
|
|
child: CustomPaint(
|
|
painter: _AngleIndicatorPainter(angle: _angle),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildColorEditor() {
|
|
final stop = _stops[_selectedStopIndex!];
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'节点颜色',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.black.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
EditorColorPicker(
|
|
currentColor: stop.color,
|
|
onChanged: (Color color) {
|
|
setState(() {
|
|
_stops[_selectedStopIndex!] = _stops[_selectedStopIndex!]
|
|
.copyWith(color: color);
|
|
});
|
|
_notifyChanged();
|
|
},
|
|
compact: true,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildActionButtons() {
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
CupertinoButton(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
color: CupertinoColors.systemBlue.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
onPressed: _addStop,
|
|
child: const Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
CupertinoIcons.plus,
|
|
size: 16,
|
|
color: CupertinoColors.systemBlue,
|
|
),
|
|
SizedBox(width: 4),
|
|
Text(
|
|
'添加节点',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: CupertinoColors.systemBlue,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (_stops.length > 2)
|
|
CupertinoButton(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
color: CupertinoColors.systemRed.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
onPressed: _removeSelectedStop,
|
|
child: const Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
CupertinoIcons.minus,
|
|
size: 16,
|
|
color: CupertinoColors.systemRed,
|
|
),
|
|
SizedBox(width: 4),
|
|
Text(
|
|
'删除节点',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: CupertinoColors.systemRed,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _addStop() {
|
|
const newPos = 0.5;
|
|
final leftStop = _stops.where((s) => s.position < newPos).lastOrNull;
|
|
final rightStop = _stops.where((s) => s.position > newPos).firstOrNull;
|
|
|
|
Color newColor;
|
|
if (leftStop != null && rightStop != null) {
|
|
newColor =
|
|
Color.lerp(leftStop.color, rightStop.color, 0.5) ??
|
|
const Color(0xFF888888);
|
|
} else {
|
|
newColor = const Color(0xFF888888);
|
|
}
|
|
|
|
setState(() {
|
|
_stops.add(GradientStop(color: newColor, position: newPos));
|
|
_selectedStopIndex = _stops.length - 1;
|
|
});
|
|
_notifyChanged();
|
|
}
|
|
|
|
void _removeSelectedStop() {
|
|
if (_selectedStopIndex == null || _stops.length <= 2) return;
|
|
setState(() {
|
|
_stops.removeAt(_selectedStopIndex!);
|
|
_selectedStopIndex = _selectedStopIndex! >= _stops.length
|
|
? _stops.length - 1
|
|
: _selectedStopIndex;
|
|
});
|
|
_notifyChanged();
|
|
}
|
|
|
|
void _notifyChanged() {
|
|
widget.onChanged(_stops);
|
|
widget.onAngleChanged(_angle);
|
|
}
|
|
}
|
|
|
|
class _AngleIndicatorPainter extends CustomPainter {
|
|
const _AngleIndicatorPainter({required this.angle});
|
|
|
|
final double angle;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final center = Offset(size.width / 2, size.height / 2);
|
|
final radius = size.width / 2 - 4;
|
|
|
|
final circlePaint = Paint()
|
|
..color = const Color(0xFFE5E5EA)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 2;
|
|
canvas.drawCircle(center, radius, circlePaint);
|
|
|
|
final rad = angle * pi / 180;
|
|
final endX = center.dx + radius * sin(rad);
|
|
final endY = center.dy - radius * cos(rad);
|
|
|
|
final linePaint = Paint()
|
|
..color = LightColors.primary
|
|
..strokeWidth = 2.5
|
|
..strokeCap = StrokeCap.round;
|
|
canvas.drawLine(center, Offset(endX, endY), linePaint);
|
|
|
|
final dotPaint = Paint()..color = LightColors.primary;
|
|
canvas.drawCircle(Offset(endX, endY), 3, dotPaint);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _AngleIndicatorPainter oldDelegate) {
|
|
return angle != oldDelegate.angle;
|
|
}
|
|
}
|