refactor(editor): 将画布样式相关功能移至独立文件 fix(editor): 修复描边坐标偏移问题 feat(export): 新增applyCanvasStyle后处理方法 feat(settings): 实现CanvasStyleModel持久化存储 fix(feed): 互动操作增加登录检查避免401错误 feat(wallpaper): 实现无限下拉加载功能 fix(text): 修复富文本编辑器初始化问题
1172 lines
34 KiB
Dart
1172 lines
34 KiB
Dart
// ============================================================
|
|
// 闲言APP — 画布样式编辑面板
|
|
// 创建时间: 2026-05-05
|
|
// 更新时间: 2026-05-05
|
|
// 作用: 画布边缘样式编辑 — 圆角+边框+阴影+叠层效果
|
|
// 独立文件,后续扩展开发
|
|
// 上次更新: 初始创建 — 4大编辑区域+实时预览
|
|
// ============================================================
|
|
|
|
import 'package:flex_color_picker/flex_color_picker.dart';
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart'
|
|
show Colors, showModalBottomSheet, DraggableScrollableSheet;
|
|
|
|
import 'package:xianyan/core/theme/app_theme.dart';
|
|
import 'package:pro_image_editor/core/models/canvas_style_model.dart';
|
|
|
|
class CanvasStyleSheet extends StatefulWidget {
|
|
const CanvasStyleSheet({
|
|
super.key,
|
|
required this.style,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final CanvasStyleModel style;
|
|
final ValueChanged<CanvasStyleModel> onChanged;
|
|
|
|
@override
|
|
State<CanvasStyleSheet> createState() => _CanvasStyleSheetState();
|
|
|
|
static void show({
|
|
required BuildContext context,
|
|
required CanvasStyleModel style,
|
|
required ValueChanged<CanvasStyleModel> onChanged,
|
|
}) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (_) => DraggableScrollableSheet(
|
|
initialChildSize: 0.75,
|
|
minChildSize: 0.5,
|
|
maxChildSize: 0.9,
|
|
expand: false,
|
|
builder: (context, scrollController) => Container(
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.ext(context).bgPrimary,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
child: CanvasStyleSheet(style: style, onChanged: onChanged),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CanvasStyleSheetState extends State<CanvasStyleSheet> {
|
|
late CanvasStyleModel _style;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_style = widget.style;
|
|
}
|
|
|
|
void _update(CanvasStyleModel newStyle) {
|
|
setState(() => _style = newStyle);
|
|
widget.onChanged(newStyle);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ext = AppTheme.ext(context);
|
|
|
|
return Column(
|
|
children: [
|
|
_buildHandle(ext),
|
|
_buildHeader(ext),
|
|
Expanded(
|
|
child: ListView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
children: [
|
|
const SizedBox(height: 8),
|
|
_buildPreview(ext),
|
|
const SizedBox(height: 16),
|
|
_buildRadiusSection(ext),
|
|
const SizedBox(height: 20),
|
|
_buildBorderSection(ext),
|
|
const SizedBox(height: 20),
|
|
_buildShadowSection(ext),
|
|
const SizedBox(height: 20),
|
|
_buildStackSection(ext),
|
|
const SizedBox(height: 20),
|
|
_buildOuterMarginSection(ext),
|
|
const SizedBox(height: 40),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildHandle(AppThemeExtension ext) {
|
|
return Center(
|
|
child: Container(
|
|
width: 40,
|
|
height: 5,
|
|
margin: const EdgeInsets.only(top: 10, bottom: 4),
|
|
decoration: BoxDecoration(
|
|
color: ext.textHint.withValues(alpha: 0.3),
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader(AppThemeExtension ext) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
|
child: Row(
|
|
children: [
|
|
Icon(CupertinoIcons.rectangle, size: 20, color: ext.accent),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'画布样式',
|
|
style: TextStyle(
|
|
color: ext.textPrimary,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
GestureDetector(
|
|
onTap: () => Navigator.pop(context),
|
|
child: Container(
|
|
width: 28,
|
|
height: 28,
|
|
decoration: BoxDecoration(
|
|
color: ext.bgCard.withValues(alpha: 0.6),
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
child: Icon(
|
|
CupertinoIcons.xmark,
|
|
color: ext.textSecondary,
|
|
size: 14,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ─── 实时预览 ───
|
|
|
|
Widget _buildPreview(AppThemeExtension ext) {
|
|
return Container(
|
|
height: 160,
|
|
alignment: Alignment.center,
|
|
child: _CanvasPreviewCard(style: _style, ext: ext),
|
|
);
|
|
}
|
|
|
|
// ─── 圆角 ───
|
|
|
|
Widget _buildRadiusSection(AppThemeExtension ext) {
|
|
return _SectionCard(
|
|
ext: ext,
|
|
icon: CupertinoIcons.rectangle,
|
|
title: '圆角',
|
|
value: '${_style.borderRadius.round()}px',
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
_RadiusPreset(
|
|
ext: ext,
|
|
label: '直角',
|
|
radius: 0,
|
|
current: _style.borderRadius,
|
|
onTap: () => _update(_style.copyWith(borderRadius: 0)),
|
|
),
|
|
const SizedBox(width: 6),
|
|
_RadiusPreset(
|
|
ext: ext,
|
|
label: '小',
|
|
radius: 8,
|
|
current: _style.borderRadius,
|
|
onTap: () => _update(_style.copyWith(borderRadius: 8)),
|
|
),
|
|
const SizedBox(width: 6),
|
|
_RadiusPreset(
|
|
ext: ext,
|
|
label: '中',
|
|
radius: 20,
|
|
current: _style.borderRadius,
|
|
onTap: () => _update(_style.copyWith(borderRadius: 20)),
|
|
),
|
|
const SizedBox(width: 6),
|
|
_RadiusPreset(
|
|
ext: ext,
|
|
label: '大',
|
|
radius: 40,
|
|
current: _style.borderRadius,
|
|
onTap: () => _update(_style.copyWith(borderRadius: 40)),
|
|
),
|
|
const SizedBox(width: 6),
|
|
_RadiusPreset(
|
|
ext: ext,
|
|
label: '超大',
|
|
radius: 60,
|
|
current: _style.borderRadius,
|
|
onTap: () => _update(_style.copyWith(borderRadius: 60)),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
CupertinoSlider(
|
|
value: _style.borderRadius,
|
|
min: 0,
|
|
max: 80,
|
|
divisions: 40,
|
|
activeColor: ext.accent,
|
|
onChanged: (v) => _update(_style.copyWith(borderRadius: v)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ─── 边框 ───
|
|
|
|
Widget _buildBorderSection(AppThemeExtension ext) {
|
|
return _SectionCard(
|
|
ext: ext,
|
|
icon: CupertinoIcons.square,
|
|
title: '边框',
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: CanvasBorderStyle.values.map((s) {
|
|
final isActive = _style.borderStyle == s;
|
|
return Expanded(
|
|
child: GestureDetector(
|
|
onTap: () => _update(_style.copyWith(borderStyle: s)),
|
|
child: _PresetChip(
|
|
ext: ext,
|
|
label: s.label,
|
|
isActive: isActive,
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
if (_style.borderStyle != CanvasBorderStyle.none) ...[
|
|
const SizedBox(height: 12),
|
|
_buildSliderRow(
|
|
ext,
|
|
'粗细',
|
|
_style.borderWidth,
|
|
0.5,
|
|
8.0,
|
|
15,
|
|
(v) => _update(_style.copyWith(borderWidth: v)),
|
|
suffix: '${_style.borderWidth.toStringAsFixed(1)}px',
|
|
),
|
|
const SizedBox(height: 10),
|
|
_buildColorRow(
|
|
ext,
|
|
'颜色',
|
|
_style.borderColor,
|
|
(c) => _update(_style.copyWith(borderColor: c)),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ─── 阴影 ───
|
|
|
|
Widget _buildShadowSection(AppThemeExtension ext) {
|
|
return _SectionCard(
|
|
ext: ext,
|
|
icon: CupertinoIcons.circle_lefthalf_fill,
|
|
title: '阴影',
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: () =>
|
|
_update(_style.copyWith(shadowBlur: 0, shadowOpacity: 0)),
|
|
child: _PresetChip(
|
|
ext: ext,
|
|
label: '无',
|
|
isActive: !_style.hasShadow,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: () => _update(
|
|
_style.copyWith(shadowBlur: 8, shadowOpacity: 0.2),
|
|
),
|
|
child: _PresetChip(
|
|
ext: ext,
|
|
label: '淡',
|
|
isActive: _style.hasShadow && _style.shadowOpacity < 0.4,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: () => _update(
|
|
_style.copyWith(shadowBlur: 16, shadowOpacity: 0.45),
|
|
),
|
|
child: _PresetChip(
|
|
ext: ext,
|
|
label: '浓',
|
|
isActive: _style.hasShadow && _style.shadowOpacity >= 0.4,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_style.hasShadow) ...[
|
|
const SizedBox(height: 12),
|
|
_buildSliderRow(
|
|
ext,
|
|
'模糊',
|
|
_style.shadowBlur,
|
|
5,
|
|
50,
|
|
45,
|
|
(v) => _update(_style.copyWith(shadowBlur: v)),
|
|
suffix: '${_style.shadowBlur.round()}px',
|
|
),
|
|
const SizedBox(height: 8),
|
|
_buildSliderRow(
|
|
ext,
|
|
'浓度',
|
|
_style.shadowOpacity,
|
|
0.05,
|
|
1.0,
|
|
19,
|
|
(v) => _update(_style.copyWith(shadowOpacity: v)),
|
|
suffix: '${(_style.shadowOpacity * 100).round()}%',
|
|
),
|
|
const SizedBox(height: 8),
|
|
_buildSliderRow(
|
|
ext,
|
|
'扩散',
|
|
_style.shadowSpread,
|
|
0,
|
|
30,
|
|
30,
|
|
(v) => _update(_style.copyWith(shadowSpread: v)),
|
|
suffix: '${_style.shadowSpread.round()}px',
|
|
),
|
|
const SizedBox(height: 8),
|
|
_buildSliderRow(
|
|
ext,
|
|
'↔ 偏移',
|
|
_style.shadowOffsetX.abs(),
|
|
0,
|
|
30,
|
|
30,
|
|
(v) => _update(
|
|
_style.copyWith(
|
|
shadowOffsetX: _style.shadowOffsetX < 0 ? -v : v,
|
|
),
|
|
),
|
|
suffix: '${_style.shadowOffsetX.round()}px',
|
|
),
|
|
const SizedBox(height: 8),
|
|
_buildSliderRow(
|
|
ext,
|
|
'↕ 偏移',
|
|
_style.shadowOffsetY.abs(),
|
|
0,
|
|
30,
|
|
30,
|
|
(v) => _update(
|
|
_style.copyWith(
|
|
shadowOffsetY: _style.shadowOffsetY < 0 ? -v : v,
|
|
),
|
|
),
|
|
suffix: '${_style.shadowOffsetY.round()}px',
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
_buildOffsetDirectionButton(
|
|
ext,
|
|
icon: CupertinoIcons.arrow_left,
|
|
label: '左',
|
|
onTap: () => _update(
|
|
_style.copyWith(
|
|
shadowOffsetX: (_style.shadowOffsetX - 2).clamp(
|
|
-30.0,
|
|
30.0,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
_buildOffsetDirectionButton(
|
|
ext,
|
|
icon: CupertinoIcons.arrow_right,
|
|
label: '右',
|
|
onTap: () => _update(
|
|
_style.copyWith(
|
|
shadowOffsetX: (_style.shadowOffsetX + 2).clamp(
|
|
-30.0,
|
|
30.0,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
_buildOffsetDirectionButton(
|
|
ext,
|
|
icon: CupertinoIcons.arrow_up,
|
|
label: '上',
|
|
onTap: () => _update(
|
|
_style.copyWith(
|
|
shadowOffsetY: (_style.shadowOffsetY - 2).clamp(
|
|
-30.0,
|
|
30.0,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
_buildOffsetDirectionButton(
|
|
ext,
|
|
icon: CupertinoIcons.arrow_down,
|
|
label: '下',
|
|
onTap: () => _update(
|
|
_style.copyWith(
|
|
shadowOffsetY: (_style.shadowOffsetY + 2).clamp(
|
|
-30.0,
|
|
30.0,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
_buildOffsetDirectionButton(
|
|
ext,
|
|
icon: CupertinoIcons.arrow_uturn_left,
|
|
label: '重置',
|
|
onTap: () => _update(
|
|
_style.copyWith(shadowOffsetX: 0, shadowOffsetY: 0),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ─── 叠层 ───
|
|
|
|
Widget _buildStackSection(AppThemeExtension ext) {
|
|
return _SectionCard(
|
|
ext: ext,
|
|
icon: CupertinoIcons.square_stack_3d_down_right,
|
|
title: '叠层',
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildSliderRow(
|
|
ext,
|
|
'层数',
|
|
_style.stackLayers.toDouble(),
|
|
0,
|
|
5,
|
|
5,
|
|
(v) => _update(_style.copyWith(stackLayers: v.round())),
|
|
suffix: '${_style.stackLayers}层',
|
|
),
|
|
if (_style.hasStack) ...[
|
|
const SizedBox(height: 10),
|
|
_buildSliderRow(
|
|
ext,
|
|
'间距',
|
|
_style.stackDistance,
|
|
2,
|
|
30,
|
|
14,
|
|
(v) => _update(_style.copyWith(stackDistance: v)),
|
|
suffix: '${_style.stackDistance.round()}px',
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'位置',
|
|
style: TextStyle(
|
|
color: ext.textSecondary,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: CanvasStackPosition.values.map((p) {
|
|
final isActive = _style.stackPosition == p;
|
|
return Expanded(
|
|
child: GestureDetector(
|
|
onTap: () => _update(_style.copyWith(stackPosition: p)),
|
|
child: _PresetChip(
|
|
ext: ext,
|
|
label: p.label,
|
|
isActive: isActive,
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ─── 外边距 ───
|
|
|
|
Widget _buildOuterMarginSection(AppThemeExtension ext) {
|
|
return _SectionCard(
|
|
ext: ext,
|
|
icon: CupertinoIcons.resize,
|
|
title: '外边距',
|
|
// subtitle: '控制画布与最外层视图的距离',
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: () => _update(_style.copyWith(outerMargin: -1.0)),
|
|
child: _PresetChip(
|
|
ext: ext,
|
|
label: '自动',
|
|
isActive: !_style.hasOuterMargin,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: () => _update(_style.copyWith(outerMargin: 0.0)),
|
|
child: _PresetChip(
|
|
ext: ext,
|
|
label: '紧贴',
|
|
isActive: _style.outerMargin == 0,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: () => _update(_style.copyWith(outerMargin: 10.0)),
|
|
child: _PresetChip(
|
|
ext: ext,
|
|
label: '适中',
|
|
isActive:
|
|
_style.outerMargin > 0 && _style.outerMargin <= 15,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: () => _update(_style.copyWith(outerMargin: 30.0)),
|
|
child: _PresetChip(
|
|
ext: ext,
|
|
label: '宽松',
|
|
isActive: _style.outerMargin > 15,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_style.hasOuterMargin) ...[
|
|
const SizedBox(height: 12),
|
|
_buildSliderRow(
|
|
ext,
|
|
'边距',
|
|
_style.outerMargin,
|
|
0,
|
|
60,
|
|
60,
|
|
(v) => _update(_style.copyWith(outerMargin: v)),
|
|
suffix: '${_style.outerMargin.round()}px',
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ─── 通用组件 ───
|
|
|
|
Widget _buildSliderRow(
|
|
AppThemeExtension ext,
|
|
String label,
|
|
double value,
|
|
double min,
|
|
double max,
|
|
int divisions,
|
|
ValueChanged<double> onChanged, {
|
|
String suffix = '',
|
|
}) {
|
|
return Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 36,
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(color: ext.textSecondary, fontSize: 12),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: CupertinoSlider(
|
|
value: value.clamp(min, max),
|
|
min: min,
|
|
max: max,
|
|
divisions: divisions,
|
|
activeColor: ext.accent,
|
|
onChanged: onChanged,
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 48,
|
|
child: Text(
|
|
suffix,
|
|
style: TextStyle(
|
|
color: ext.accent,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
fontFeatures: const [FontFeature.tabularFigures()],
|
|
),
|
|
textAlign: TextAlign.right,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildOffsetDirectionButton(
|
|
AppThemeExtension ext, {
|
|
required IconData icon,
|
|
required String label,
|
|
required VoidCallback onTap,
|
|
}) {
|
|
return Expanded(
|
|
child: GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: ext.bgSecondary,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: ext.bgCard.withValues(alpha: 0.3),
|
|
width: 0.5,
|
|
),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 14, color: ext.textSecondary),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
label,
|
|
style: TextStyle(fontSize: 10, color: ext.textSecondary),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildColorRow(
|
|
AppThemeExtension ext,
|
|
String label,
|
|
Color color,
|
|
ValueChanged<Color> onChanged,
|
|
) {
|
|
return Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 36,
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(color: ext.textSecondary, fontSize: 12),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
GestureDetector(
|
|
onTap: () => _showColorPicker(ext, color, onChanged),
|
|
child: Container(
|
|
width: 28,
|
|
height: 28,
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(
|
|
color: ext.textHint.withValues(alpha: 0.3),
|
|
width: 0.5,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'#${color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}',
|
|
style: TextStyle(
|
|
color: ext.textSecondary,
|
|
fontSize: 11,
|
|
fontFeatures: const [FontFeature.tabularFigures()],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _showColorPicker(
|
|
AppThemeExtension ext,
|
|
Color current,
|
|
ValueChanged<Color> onChanged,
|
|
) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (sheetContext) => Container(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(context).size.height * 0.7,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: ext.bgPrimary,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
'选择颜色',
|
|
style: TextStyle(
|
|
color: ext.textPrimary,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
CupertinoButton(
|
|
padding: EdgeInsets.zero,
|
|
minSize: 32,
|
|
onPressed: () => Navigator.pop(sheetContext),
|
|
child: Text(
|
|
'完成',
|
|
style: TextStyle(color: ext.accent, fontSize: 14),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Flexible(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
|
child: ColorPicker(
|
|
color: current,
|
|
onColorChanged: onChanged,
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 8,
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
wheelDiameter: 200,
|
|
showMaterialName: false,
|
|
showColorName: false,
|
|
showColorCode: true,
|
|
colorCodeHasColor: true,
|
|
pickersEnabled: const {
|
|
ColorPickerType.wheel: true,
|
|
ColorPickerType.accent: false,
|
|
ColorPickerType.primary: false,
|
|
ColorPickerType.both: false,
|
|
},
|
|
copyPasteBehavior: const ColorPickerCopyPasteBehavior(
|
|
copyButton: false,
|
|
pasteButton: false,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 区域卡片
|
|
// ============================================================
|
|
|
|
class _SectionCard extends StatelessWidget {
|
|
const _SectionCard({
|
|
required this.ext,
|
|
required this.icon,
|
|
required this.title,
|
|
this.value,
|
|
required this.child,
|
|
});
|
|
|
|
final AppThemeExtension ext;
|
|
final IconData icon;
|
|
final String title;
|
|
final String? value;
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: ext.bgSecondary.withValues(alpha: 0.5),
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(
|
|
color: ext.bgCard.withValues(alpha: 0.3),
|
|
width: 0.5,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(icon, size: 16, color: ext.accent),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
color: ext.textPrimary,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
if (value != null) ...[
|
|
const Spacer(),
|
|
Text(
|
|
value!,
|
|
style: TextStyle(
|
|
color: ext.accent,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
fontFeatures: const [FontFeature.tabularFigures()],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
child,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 预设芯片
|
|
// ============================================================
|
|
|
|
class _PresetChip extends StatelessWidget {
|
|
const _PresetChip({
|
|
required this.ext,
|
|
required this.label,
|
|
required this.isActive,
|
|
});
|
|
|
|
final AppThemeExtension ext;
|
|
final String label;
|
|
final bool isActive;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: isActive ? ext.accent.withValues(alpha: 0.12) : ext.bgSecondary,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(
|
|
color: isActive
|
|
? ext.accent.withValues(alpha: 0.4)
|
|
: ext.bgCard.withValues(alpha: 0.3),
|
|
width: 0.5,
|
|
),
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
|
|
color: isActive ? ext.accent : ext.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 圆角预设
|
|
// ============================================================
|
|
|
|
class _RadiusPreset extends StatelessWidget {
|
|
const _RadiusPreset({
|
|
required this.ext,
|
|
required this.label,
|
|
required this.radius,
|
|
required this.current,
|
|
required this.onTap,
|
|
});
|
|
|
|
final AppThemeExtension ext;
|
|
final String label;
|
|
final double radius;
|
|
final double current;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isActive = (current - radius).abs() < 2;
|
|
return Expanded(
|
|
child: GestureDetector(
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: isActive
|
|
? ext.accent.withValues(alpha: 0.12)
|
|
: ext.bgSecondary,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(
|
|
color: isActive
|
|
? ext.accent.withValues(alpha: 0.4)
|
|
: ext.bgCard.withValues(alpha: 0.3),
|
|
width: 0.5,
|
|
),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
width: 24,
|
|
height: 24,
|
|
decoration: BoxDecoration(
|
|
color: ext.bgCard,
|
|
borderRadius: BorderRadius.circular(radius.clamp(0, 12)),
|
|
border: Border.all(
|
|
color: isActive ? ext.accent : ext.textHint,
|
|
width: 1,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
|
|
color: isActive ? ext.accent : ext.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 实时预览卡片
|
|
// ============================================================
|
|
|
|
class _CanvasPreviewCard extends StatelessWidget {
|
|
const _CanvasPreviewCard({required this.style, required this.ext});
|
|
|
|
final CanvasStyleModel style;
|
|
final AppThemeExtension ext;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final stackWidgets = <Widget>[];
|
|
|
|
if (style.hasStack) {
|
|
for (int i = style.stackLayers; i >= 1; i--) {
|
|
stackWidgets.add(_buildStackLayer(i));
|
|
}
|
|
}
|
|
|
|
stackWidgets.add(_buildMainCard());
|
|
|
|
return Stack(
|
|
alignment: style.stackPosition.alignment,
|
|
clipBehavior: Clip.none,
|
|
children: stackWidgets,
|
|
);
|
|
}
|
|
|
|
Widget _buildStackLayer(int index) {
|
|
final offset = style.stackDistance * index;
|
|
final dx = style.stackPosition == CanvasStackPosition.left
|
|
? -offset
|
|
: style.stackPosition == CanvasStackPosition.right
|
|
? offset
|
|
: 0.0;
|
|
final dy = style.stackPosition == CanvasStackPosition.top
|
|
? -offset
|
|
: style.stackPosition == CanvasStackPosition.bottom
|
|
? offset
|
|
: 0.0;
|
|
|
|
return Transform.translate(
|
|
offset: Offset(dx, dy),
|
|
child: Container(
|
|
width: 120,
|
|
height: 80,
|
|
decoration: BoxDecoration(
|
|
color: ext.bgCard.withValues(alpha: 0.3),
|
|
borderRadius: BorderRadius.circular(style.borderRadius.clamp(0, 40)),
|
|
border: style.hasBorder
|
|
? Border.all(
|
|
color: style.borderColor.withValues(alpha: 0.2),
|
|
width: style.borderWidth * 0.5,
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMainCard() {
|
|
final radius = style.borderRadius.clamp(0.0, 40.0).toDouble();
|
|
Widget card = Container(
|
|
width: 120,
|
|
height: 80,
|
|
decoration: BoxDecoration(
|
|
color: ext.bgElevated,
|
|
borderRadius: BorderRadius.circular(radius),
|
|
boxShadow: style.hasShadow
|
|
? [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: style.shadowOpacity),
|
|
blurRadius: (style.shadowBlur * 0.3).toDouble(),
|
|
spreadRadius: (style.shadowSpread * 0.3).toDouble(),
|
|
offset: Offset(
|
|
(style.shadowOffsetX * 0.3).toDouble(),
|
|
(style.shadowOffsetY != 0
|
|
? style.shadowOffsetY * 0.3
|
|
: style.shadowBlur * 0.08)
|
|
.toDouble(),
|
|
),
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
'预览',
|
|
style: TextStyle(color: ext.textSecondary, fontSize: 12),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (style.hasBorder) {
|
|
if (style.borderStyle == CanvasBorderStyle.dashed) {
|
|
card = CustomPaint(
|
|
painter: _PreviewDashedPainter(
|
|
borderColor: style.borderColor,
|
|
borderWidth: style.borderWidth,
|
|
borderRadius: radius,
|
|
),
|
|
child: card,
|
|
);
|
|
} else {
|
|
card = DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(radius),
|
|
border: Border.all(
|
|
color: style.borderColor,
|
|
width: style.borderWidth,
|
|
),
|
|
),
|
|
child: card,
|
|
);
|
|
}
|
|
}
|
|
|
|
return card;
|
|
}
|
|
}
|
|
|
|
class _PreviewDashedPainter extends CustomPainter {
|
|
_PreviewDashedPainter({
|
|
required this.borderColor,
|
|
required this.borderWidth,
|
|
required this.borderRadius,
|
|
});
|
|
|
|
final Color borderColor;
|
|
final double borderWidth;
|
|
final double borderRadius;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..color = borderColor
|
|
..strokeWidth = borderWidth
|
|
..style = PaintingStyle.stroke;
|
|
|
|
final half = borderWidth / 2;
|
|
final rrect = RRect.fromRectAndRadius(
|
|
Rect.fromLTWH(
|
|
half,
|
|
half,
|
|
size.width - borderWidth,
|
|
size.height - borderWidth,
|
|
),
|
|
Radius.circular(borderRadius),
|
|
);
|
|
|
|
const dashWidth = 6.0;
|
|
const dashGap = 4.0;
|
|
final path = Path()..addRRect(rrect);
|
|
|
|
final metrics = path.computeMetrics();
|
|
for (final metric in metrics) {
|
|
double distance = 0;
|
|
while (distance < metric.length) {
|
|
final end = (distance + dashWidth).clamp(0.0, metric.length).toDouble();
|
|
canvas.drawPath(metric.extractPath(distance, end), paint);
|
|
distance += dashWidth + dashGap;
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _PreviewDashedPainter old) =>
|
|
old.borderColor != borderColor ||
|
|
old.borderWidth != borderWidth ||
|
|
old.borderRadius != borderRadius;
|
|
}
|