水印相机前端
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../../models/watermark.dart';
|
||||
import '../../../providers/watermark_provider.dart';
|
||||
import '../../../utils/toast.dart';
|
||||
import 'watermark_edit_page.dart';
|
||||
import 'widgets/watermark_settings_sheet.dart';
|
||||
|
||||
/// 水印相机模块入口:拍照 / 相册选图 → 进编辑页加水印
|
||||
///
|
||||
/// 刻意不做自绘相机预览(原项目也是调系统相机):省掉一整套相机权限、
|
||||
/// 预览流、方向处理的复杂度,代价只是没法实时看到水印。
|
||||
class WatermarkCameraPage extends ConsumerWidget {
|
||||
const WatermarkCameraPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(watermarkSettingsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('水印相机'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '水印设置',
|
||||
icon: const Icon(Icons.tune),
|
||||
onPressed: () => showWatermarkSettingsSheet(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
_introCard(),
|
||||
const SizedBox(height: 20),
|
||||
_captureButton(
|
||||
icon: Icons.camera_alt,
|
||||
label: '拍照',
|
||||
primary: true,
|
||||
onTap: () => _pick(context, ImageSource.camera),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_captureButton(
|
||||
icon: Icons.photo_library_outlined,
|
||||
label: '从相册选',
|
||||
primary: false,
|
||||
onTap: () => _pick(context, ImageSource.gallery),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_settingsCard(context, settings),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _introCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'拍一张,自动加水印',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'时间、地点、天气自动填好,可以拖动位置、换颜色。\n'
|
||||
'加好水印的照片直接存进手机相册;原图会同时备份到服务器。',
|
||||
style: TextStyle(fontSize: 12.5, height: 1.6, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _captureButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required bool primary,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
final style = FilledButton.styleFrom(
|
||||
backgroundColor: primary ? const Color(0xFFC8956E) : Colors.white,
|
||||
foregroundColor: primary ? Colors.white : const Color(0xFFC8956E),
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
side: primary
|
||||
? BorderSide.none
|
||||
: const BorderSide(color: Color(0xFFC8956E)),
|
||||
),
|
||||
elevation: primary ? 0 : 0,
|
||||
);
|
||||
|
||||
return FilledButton.icon(
|
||||
onPressed: onTap,
|
||||
style: style,
|
||||
icon: Icon(icon, size: 20),
|
||||
label: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _settingsCard(BuildContext context, WatermarkSettings settings) {
|
||||
final items = <String>[
|
||||
if (settings.showTime) '时间',
|
||||
if (settings.showAddress) '地点',
|
||||
if (settings.showWeather) '天气',
|
||||
if (settings.showRemark) '备注',
|
||||
if (settings.showBrand) '品牌角标',
|
||||
];
|
||||
|
||||
return InkWell(
|
||||
onTap: () => showWatermarkSettingsSheet(context),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 14, 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'当前水印内容',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (items.isEmpty)
|
||||
const Text(
|
||||
'全部关闭(照片不会加任何水印)',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: items
|
||||
.map(
|
||||
(t) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3E6),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
t,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF8D6E63),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: Color(0xFFBCAAA4)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 选图。**不传 imageQuality / maxWidth** —— 要的就是原图,
|
||||
/// 编辑页会在合成时按最长边 1920 缩放,原始文件则原样备份到服务器。
|
||||
Future<void> _pick(BuildContext context, ImageSource source) async {
|
||||
try {
|
||||
final file = await ImagePicker().pickImage(source: source);
|
||||
if (file == null) return;
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => WatermarkEditPage(imagePath: file.path)),
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
showToast(context, '打开相机/相册失败:$e', error: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gal/gal.dart';
|
||||
|
||||
import '../../../api/geo_api.dart';
|
||||
import '../../../api/photo_api.dart';
|
||||
import '../../../core/network/api_exception.dart';
|
||||
import '../../../models/watermark.dart';
|
||||
import '../../../providers/watermark_provider.dart';
|
||||
import '../../../utils/toast.dart';
|
||||
import '../../../widgets/state_views.dart';
|
||||
import 'watermark_painter.dart';
|
||||
import 'widgets/watermark_content_sheet.dart';
|
||||
import 'widgets/watermark_settings_sheet.dart';
|
||||
|
||||
/// 水印编辑页:预览(照片 + 水印)+ 拖动定位 + 保存到相册
|
||||
///
|
||||
/// 水印是**本地合成**的 —— 服务器那份只是原图备份。
|
||||
class WatermarkEditPage extends ConsumerStatefulWidget {
|
||||
const WatermarkEditPage({super.key, required this.imagePath});
|
||||
|
||||
final String imagePath;
|
||||
|
||||
@override
|
||||
ConsumerState<WatermarkEditPage> createState() => _WatermarkEditPageState();
|
||||
}
|
||||
|
||||
enum _UploadStatus { waiting, uploading, done, failed }
|
||||
|
||||
class _WatermarkEditPageState extends ConsumerState<WatermarkEditPage> {
|
||||
Size? _imageSize;
|
||||
String? _imageError;
|
||||
|
||||
late WatermarkData _data;
|
||||
|
||||
bool _locating = false;
|
||||
String? _locateError;
|
||||
|
||||
/// 定位到的坐标(上传原图时一并存到服务器)
|
||||
double? _latitude;
|
||||
double? _longitude;
|
||||
|
||||
_UploadStatus _upload = _UploadStatus.waiting;
|
||||
String? _uploadError;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_data = WatermarkData(time: DateTime.now());
|
||||
_loadImageSize();
|
||||
// 先定位、再上传 —— 这样服务器那份记录能带上地址和天气
|
||||
_locateThenUpload();
|
||||
}
|
||||
|
||||
// ---------------- 图片尺寸 ----------------
|
||||
|
||||
Future<void> _loadImageSize() async {
|
||||
setState(() => _imageError = null);
|
||||
try {
|
||||
// 只读文件头拿尺寸,不整张解码(12MP 全解码要 48MB 内存)
|
||||
final bytes = await File(widget.imagePath).readAsBytes();
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
|
||||
final descriptor = await ui.ImageDescriptor.encoded(buffer);
|
||||
final size = Size(descriptor.width.toDouble(), descriptor.height.toDouble());
|
||||
descriptor.dispose();
|
||||
buffer.dispose();
|
||||
if (!mounted) return;
|
||||
setState(() => _imageSize = size);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _imageError = '这张照片读不出来,换一张试试');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 定位 ----------------
|
||||
|
||||
Future<void> _locateThenUpload() async {
|
||||
await _locate();
|
||||
await _uploadOriginal();
|
||||
}
|
||||
|
||||
Future<void> _locate() async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_locating = true;
|
||||
_locateError = null;
|
||||
});
|
||||
|
||||
final snapshot = await ref.read(geoApiProvider).resolve();
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_locating = false;
|
||||
_locateError = snapshot.error;
|
||||
_latitude = snapshot.latitude;
|
||||
_longitude = snapshot.longitude;
|
||||
_data = _data.copyWith(
|
||||
address: snapshot.address,
|
||||
weather: snapshot.weather,
|
||||
temperature: snapshot.temperature,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 上传原图 ----------------
|
||||
|
||||
Future<void> _uploadOriginal() async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_upload = _UploadStatus.uploading;
|
||||
_uploadError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await ref.read(photoApiProvider).upload(
|
||||
filePath: widget.imagePath,
|
||||
latitude: _latitude,
|
||||
longitude: _longitude,
|
||||
address: _data.address,
|
||||
weather: _weatherText(),
|
||||
remark: _data.remark,
|
||||
takenTime: _data.time,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _upload = _UploadStatus.done);
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_upload = _UploadStatus.failed;
|
||||
_uploadError = e.message;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_upload = _UploadStatus.failed;
|
||||
_uploadError = '网络异常';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _weatherText() {
|
||||
final joined = '${_data.weather} ${_data.temperature}'.trim();
|
||||
return joined;
|
||||
}
|
||||
|
||||
// ---------------- 保存 ----------------
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_saving) return;
|
||||
final settings = ref.read(watermarkSettingsProvider);
|
||||
final brandIcon = ref.read(brandIconProvider).value;
|
||||
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final bytes = await File(widget.imagePath).readAsBytes();
|
||||
final jpeg = await composeWatermarkedJpeg(
|
||||
originalBytes: bytes,
|
||||
settings: settings,
|
||||
data: _data,
|
||||
brandIcon: brandIcon,
|
||||
);
|
||||
|
||||
// gal 的约定:name 不带扩展名
|
||||
await Gal.putImageBytes(jpeg, name: 'qiangbao_${_timestamp()}');
|
||||
|
||||
if (!mounted) return;
|
||||
showToast(context, '已保存到相册', icon: 'check');
|
||||
} on GalException catch (e) {
|
||||
if (!mounted) return;
|
||||
showToast(context, e.type.message, error: true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showToast(context, '保存失败,请重试', error: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _timestamp() {
|
||||
final t = DateTime.now();
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
return '${t.year}${two(t.month)}${two(t.day)}_${two(t.hour)}${two(t.minute)}${two(t.second)}';
|
||||
}
|
||||
|
||||
// ---------------- 拖动 ----------------
|
||||
|
||||
Size _fitSize(Size source, Size box) {
|
||||
if (source.width <= 0 || source.height <= 0 || box.width <= 0 || box.height <= 0) {
|
||||
return Size.zero;
|
||||
}
|
||||
final scale = math.min(box.width / source.width, box.height / source.height);
|
||||
return Size(source.width * scale, source.height * scale);
|
||||
}
|
||||
|
||||
void _onDrag(DragUpdateDetails details, Size displaySize, WatermarkSettings settings) {
|
||||
final layout = layoutWatermark(displaySize, settings, _data);
|
||||
if (layout.isEmpty) return; // 正文都关掉了,没什么可拖
|
||||
|
||||
// 位置公式和绘制共用 layoutWatermark,所以拖到哪就画到哪
|
||||
final nextX = layout.origin.dx + details.delta.dx;
|
||||
final nextY = layout.origin.dy + details.delta.dy;
|
||||
|
||||
final maxX = math.max(0.0, displaySize.width - layout.cardWidth);
|
||||
final maxY = math.max(0.0, displaySize.height - layout.cardHeight);
|
||||
|
||||
ref.read(watermarkSettingsProvider.notifier).updatePosition(
|
||||
nextX.clamp(0.0, maxX) / displaySize.width,
|
||||
nextY.clamp(0.0, maxY) / displaySize.height,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- 构建 ----------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: !_saving,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('加水印'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text(
|
||||
'保存',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFC8956E),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_uploadBar(),
|
||||
_locationBar(),
|
||||
Expanded(child: _preview()),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 6),
|
||||
child: Text(
|
||||
'按住水印可以拖动位置',
|
||||
style: TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
_toolbar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _preview() {
|
||||
if (_imageError != null) {
|
||||
return Center(
|
||||
child: ErrorView(message: _imageError!, onRetry: _loadImageSize),
|
||||
);
|
||||
}
|
||||
final imageSize = _imageSize;
|
||||
if (imageSize == null) {
|
||||
return const Center(child: LoadingView(tip: '正在读取照片...'));
|
||||
}
|
||||
|
||||
final settings = ref.watch(watermarkSettingsProvider);
|
||||
final brandIcon = ref.watch(brandIconProvider).value;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final displaySize = _fitSize(imageSize, constraints.biggest);
|
||||
if (displaySize.width <= 0 || displaySize.height <= 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Center(
|
||||
child: GestureDetector(
|
||||
onPanUpdate: (d) => _onDrag(d, displaySize, settings),
|
||||
// 松手才落盘:拖动过程每帧都写 SharedPreferences 太浪费
|
||||
onPanEnd: (_) =>
|
||||
ref.read(watermarkSettingsProvider.notifier).persistPosition(),
|
||||
onPanCancel: () =>
|
||||
ref.read(watermarkSettingsProvider.notifier).persistPosition(),
|
||||
child: SizedBox(
|
||||
width: displaySize.width,
|
||||
height: displaySize.height,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.file(
|
||||
File(widget.imagePath),
|
||||
fit: BoxFit.fill, // 尺寸已按 contain 算好,这里填满即可
|
||||
gaplessPlayback: true,
|
||||
),
|
||||
CustomPaint(
|
||||
painter: WatermarkPainter(
|
||||
settings: settings,
|
||||
data: _data,
|
||||
brandIcon: brandIcon,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 原图上传状态条
|
||||
Widget _uploadBar() {
|
||||
final (String text, Color color) = switch (_upload) {
|
||||
_UploadStatus.waiting => ('准备上传原图', const Color(0xFFBCAAA4)),
|
||||
_UploadStatus.uploading => ('正在上传原图…', const Color(0xFF8D6E63)),
|
||||
_UploadStatus.done => ('原图已备份到服务器', const Color(0xFF19BE6B)),
|
||||
_UploadStatus.failed => (
|
||||
_uploadError == null ? '原图上传失败' : '原图上传失败:$_uploadError',
|
||||
const Color(0xFFE53935),
|
||||
),
|
||||
};
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: const Color(0xFFF7F4F1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.circle, size: 8, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 12, color: color),
|
||||
),
|
||||
),
|
||||
if (_upload == _UploadStatus.failed)
|
||||
GestureDetector(
|
||||
onTap: _uploadOriginal,
|
||||
child: const Text(
|
||||
'重试',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFFC8956E),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 定位/地址条
|
||||
Widget _locationBar() {
|
||||
final String text;
|
||||
if (_locating) {
|
||||
text = '正在定位…';
|
||||
} else if (_locateError != null) {
|
||||
text = _locateError!;
|
||||
} else if (_data.address.isEmpty) {
|
||||
text = '未获取到位置';
|
||||
} else {
|
||||
text = _data.address;
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 12, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on_outlined, size: 16, color: Color(0xFFC8956E)),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
color: _locateError != null
|
||||
? const Color(0xFFE53935)
|
||||
: const Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _locating ? null : _locate,
|
||||
child: const Text(
|
||||
'刷新',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFFC8956E)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _toolbar() {
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Row(
|
||||
children: [
|
||||
_toolButton(
|
||||
icon: Icons.camera_alt_outlined,
|
||||
label: '重拍',
|
||||
// 回到入口页重新拍 —— 入口页本身就是拍照页,比在这里再塞一个相机逻辑干净
|
||||
onTap: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
_toolButton(
|
||||
icon: Icons.edit_outlined,
|
||||
label: '编辑内容',
|
||||
onTap: _editContent,
|
||||
),
|
||||
_toolButton(
|
||||
icon: Icons.tune,
|
||||
label: '水印设置',
|
||||
onTap: () => showWatermarkSettingsSheet(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _editContent() async {
|
||||
final updated = await showWatermarkContentSheet(context, initial: _data);
|
||||
if (updated == null || !mounted) return;
|
||||
setState(() => _data = updated);
|
||||
}
|
||||
|
||||
Widget _toolButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 21, color: const Color(0xFF8D6E63)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
import '../../../models/watermark.dart';
|
||||
|
||||
/// 水印绘制核心 —— **预览、拖动、导出三处共用同一套几何**。
|
||||
///
|
||||
/// 关键约定:所有尺寸都按 `scale = 画布宽度 / [kWatermarkDesignWidth]` 等比缩放。
|
||||
/// 于是同一个 [paintWatermark],在预览时传屏幕显示尺寸、在导出时传图片像素尺寸,
|
||||
/// 出来的**视觉完全一致**——这就是「所见即所得」的实现方式。
|
||||
///
|
||||
/// 改样式(字号/间距/颜色/位置)只改这里,三处自动同步。
|
||||
|
||||
/// 设计稿基准宽度(沿用原水印相机项目的 1080)
|
||||
const double kWatermarkDesignWidth = 1080;
|
||||
|
||||
// ---- 设计稿尺寸(单位:设计稿像素,实际绘制时乘 scale)----
|
||||
const double _margin = 28; // 水印块到画布边缘的默认距离
|
||||
const double _lineWidth = 4; // 左侧黄色竖线宽
|
||||
const double _lineToText = 14; // 竖线到文字的水平距离
|
||||
const double _cardPaddingY = 16; // 水印块上下内边距
|
||||
const double _fontSize = 37;
|
||||
const double _rowHeight = 54;
|
||||
const double _rowGap = 8;
|
||||
const int _maxCharsPerLine = 18; // 每行最多几个字,超了折行
|
||||
|
||||
// 右下角品牌角标
|
||||
const double _brandMargin = 8;
|
||||
const double _brandIconSize = 48;
|
||||
const double _brandTextSize = 32;
|
||||
const double _brandGap = 10;
|
||||
const double _brandPaddingX = 16;
|
||||
const double _brandPaddingY = 12;
|
||||
const double _brandOpacity = 0.8;
|
||||
const String _brandText = '水印相机';
|
||||
|
||||
/// 黄色竖线的颜色(品牌装饰,不跟随文字颜色变)
|
||||
const Color _accentColor = Color(0xFFFFB800);
|
||||
|
||||
/// 水印正文块的几何信息。
|
||||
///
|
||||
/// 抽出来是为了让「拖动」能用和「绘制」完全一样的公式算位置 ——
|
||||
/// 两边各算一遍的话,迟早会漂移。
|
||||
class WatermarkLayout {
|
||||
const WatermarkLayout({
|
||||
required this.scale,
|
||||
required this.lines,
|
||||
required this.textHeight,
|
||||
required this.cardHeight,
|
||||
required this.cardWidth,
|
||||
required this.origin,
|
||||
});
|
||||
|
||||
final double scale;
|
||||
|
||||
/// 折行后的实际文本行(已按 [_maxCharsPerLine] 拆好)
|
||||
final List<String> lines;
|
||||
|
||||
/// 所有文本行的总高度
|
||||
final double textHeight;
|
||||
|
||||
/// 块总高(含上下内边距);没有内容时为 0
|
||||
final double cardHeight;
|
||||
|
||||
/// 块总宽(最宽一行 + 竖线 + 间距)
|
||||
final double cardWidth;
|
||||
|
||||
/// 块左上角在画布上的位置
|
||||
final Offset origin;
|
||||
|
||||
bool get isEmpty => lines.isEmpty;
|
||||
}
|
||||
|
||||
/// 按 [size] 算出水印块的位置和尺寸
|
||||
WatermarkLayout layoutWatermark(
|
||||
Size size,
|
||||
WatermarkSettings settings,
|
||||
WatermarkData data,
|
||||
) {
|
||||
final scale = size.width / kWatermarkDesignWidth;
|
||||
final lines = <String>[
|
||||
for (final line in buildWatermarkLines(settings, data))
|
||||
// 标了「不折行」的(时间)原样保留,其余按字数折
|
||||
...(line.wrap ? _wrapByCharCount(line.text, _maxCharsPerLine) : [line.text]),
|
||||
];
|
||||
|
||||
if (lines.isEmpty) {
|
||||
return WatermarkLayout(
|
||||
scale: scale,
|
||||
lines: const [],
|
||||
textHeight: 0,
|
||||
cardHeight: 0,
|
||||
cardWidth: 0,
|
||||
origin: Offset(
|
||||
settings.posX != null ? settings.posX! * size.width : _margin * scale,
|
||||
settings.posY != null ? settings.posY! * size.height : size.height - _margin * scale,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final rowH = _rowHeight * scale;
|
||||
final gap = _rowGap * scale;
|
||||
final padding = _cardPaddingY * scale;
|
||||
|
||||
final textHeight = lines.length * rowH + (lines.length - 1) * gap;
|
||||
final cardHeight = textHeight + padding * 2;
|
||||
|
||||
// 最宽一行决定块的宽度(拖动时用它做边界夹紧)
|
||||
var maxLineWidth = 0.0;
|
||||
for (final line in lines) {
|
||||
final painter = _layoutText(
|
||||
line,
|
||||
fontSize: _fontSize * scale,
|
||||
color: Colors.white,
|
||||
scale: scale,
|
||||
);
|
||||
if (painter.width > maxLineWidth) maxLineWidth = painter.width;
|
||||
}
|
||||
final cardWidth = _lineWidth * scale + _lineToText * scale + maxLineWidth;
|
||||
|
||||
final origin = Offset(
|
||||
settings.posX != null ? settings.posX! * size.width : _margin * scale,
|
||||
settings.posY != null
|
||||
? settings.posY! * size.height
|
||||
: size.height - _margin * scale - cardHeight,
|
||||
);
|
||||
|
||||
return WatermarkLayout(
|
||||
scale: scale,
|
||||
lines: lines,
|
||||
textHeight: textHeight,
|
||||
cardHeight: cardHeight,
|
||||
cardWidth: cardWidth,
|
||||
origin: origin,
|
||||
);
|
||||
}
|
||||
|
||||
/// 画一笔水印。
|
||||
///
|
||||
/// [size] 既可以是屏幕显示尺寸(预览),也可以是图片像素尺寸(导出)。
|
||||
void paintWatermark(
|
||||
Canvas canvas,
|
||||
Size size,
|
||||
WatermarkSettings settings,
|
||||
WatermarkData data, {
|
||||
ui.Image? brandIcon,
|
||||
}) {
|
||||
if (size.width <= 0 || size.height <= 0) return;
|
||||
|
||||
final layout = layoutWatermark(size, settings, data);
|
||||
final scale = layout.scale;
|
||||
final textColor = Color(settings.colorValue);
|
||||
|
||||
// ---- 正文块 ----
|
||||
if (!layout.isEmpty) {
|
||||
final origin = layout.origin;
|
||||
final padding = _cardPaddingY * scale;
|
||||
|
||||
// 左侧黄色竖线
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(origin.dx, origin.dy + padding, _lineWidth * scale, layout.textHeight),
|
||||
Paint()..color = _accentColor,
|
||||
);
|
||||
|
||||
// 多行文字
|
||||
final textX = origin.dx + _lineWidth * scale + _lineToText * scale;
|
||||
var y = origin.dy + padding;
|
||||
for (final line in layout.lines) {
|
||||
final painter = _layoutText(
|
||||
line,
|
||||
fontSize: _fontSize * scale,
|
||||
color: textColor,
|
||||
scale: scale,
|
||||
);
|
||||
painter.paint(canvas, Offset(textX, y));
|
||||
y += _rowHeight * scale + _rowGap * scale;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 右下角品牌角标 ----
|
||||
if (settings.showBrand) {
|
||||
_paintBrand(canvas, size, scale, brandIcon);
|
||||
}
|
||||
}
|
||||
|
||||
/// 右下角「相机图标 + 水印相机」,整体 80% 透明
|
||||
void _paintBrand(Canvas canvas, Size size, double scale, ui.Image? brandIcon) {
|
||||
final margin = _brandMargin * scale;
|
||||
final iconSize = _brandIconSize * scale;
|
||||
final gap = _brandGap * scale;
|
||||
final padX = _brandPaddingX * scale;
|
||||
final padY = _brandPaddingY * scale;
|
||||
|
||||
final textPainter = _layoutText(
|
||||
_brandText,
|
||||
fontSize: _brandTextSize * scale,
|
||||
color: Colors.white,
|
||||
bold: true,
|
||||
scale: scale,
|
||||
);
|
||||
|
||||
final hasIcon = brandIcon != null;
|
||||
final brandW = (hasIcon ? iconSize + gap : 0) + textPainter.width + padX * 2;
|
||||
final brandH = (hasIcon ? iconSize : textPainter.height) + padY * 2;
|
||||
final brandX = size.width - margin - brandW;
|
||||
final brandY = size.height - margin - brandH;
|
||||
|
||||
// saveLayer 的 paint alpha 就是整组元素的透明度(Flutter 的 Opacity 内部也这么做)
|
||||
canvas.saveLayer(
|
||||
Rect.fromLTWH(brandX, brandY, brandW, brandH),
|
||||
Paint()..color = const Color(0xFF000000).withValues(alpha: _brandOpacity),
|
||||
);
|
||||
|
||||
double contentX = brandX + padX;
|
||||
|
||||
if (hasIcon) {
|
||||
canvas.drawImageRect(
|
||||
brandIcon,
|
||||
Rect.fromLTWH(0, 0, brandIcon.width.toDouble(), brandIcon.height.toDouble()),
|
||||
Rect.fromLTWH(contentX, brandY + padY, iconSize, iconSize),
|
||||
Paint()..filterQuality = FilterQuality.medium,
|
||||
);
|
||||
contentX += iconSize + gap;
|
||||
}
|
||||
|
||||
// 文字在内容区里垂直居中
|
||||
final contentCenterY = brandY + brandH / 2;
|
||||
textPainter.paint(canvas, Offset(contentX, contentCenterY - textPainter.height / 2));
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
/// 排一段文字。加了淡阴影 —— 白字直接压在浅色照片上会糊,老项目没做这一步。
|
||||
TextPainter _layoutText(
|
||||
String text, {
|
||||
required double fontSize,
|
||||
required Color color,
|
||||
required double scale,
|
||||
bool bold = false,
|
||||
}) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
color: color,
|
||||
fontWeight: bold ? FontWeight.bold : FontWeight.normal,
|
||||
height: 1.2,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: const Color(0x99000000),
|
||||
blurRadius: 5 * scale,
|
||||
offset: Offset(0, 1 * scale),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
);
|
||||
painter.layout();
|
||||
return painter;
|
||||
}
|
||||
|
||||
/// 按字符数折行(中文按字数折最直观,和原项目一致)
|
||||
List<String> _wrapByCharCount(String text, int maxChars) {
|
||||
if (text.length <= maxChars) return [text];
|
||||
final result = <String>[];
|
||||
for (var i = 0; i < text.length; i += maxChars) {
|
||||
final end = (i + maxChars < text.length) ? i + maxChars : text.length;
|
||||
result.add(text.substring(i, end));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 预览用:把水印盖在照片上
|
||||
class WatermarkPainter extends CustomPainter {
|
||||
const WatermarkPainter({
|
||||
required this.settings,
|
||||
required this.data,
|
||||
this.brandIcon,
|
||||
});
|
||||
|
||||
final WatermarkSettings settings;
|
||||
final WatermarkData data;
|
||||
final ui.Image? brandIcon;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
paintWatermark(canvas, size, settings, data, brandIcon: brandIcon);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(WatermarkPainter old) {
|
||||
return old.settings != settings ||
|
||||
old.data != data ||
|
||||
old.brandIcon != brandIcon;
|
||||
}
|
||||
}
|
||||
|
||||
/// 把原图和水印合成为一张 JPEG。
|
||||
///
|
||||
/// 走离屏绘制而不是「截图 Widget」:这样能按**图片原始像素**出图,
|
||||
/// 不受屏幕分辨率限制,也不会因为页面滚动/遮挡出问题。
|
||||
///
|
||||
/// [maxWidth] 限制最长边(原项目也是 1920)—— 一是省内存,二是纯 Dart 的
|
||||
/// JPEG 编码在大图上会明显变慢。
|
||||
Future<Uint8List> composeWatermarkedJpeg({
|
||||
required Uint8List originalBytes,
|
||||
required WatermarkSettings settings,
|
||||
required WatermarkData data,
|
||||
ui.Image? brandIcon,
|
||||
double maxWidth = 1920,
|
||||
int quality = 92,
|
||||
}) async {
|
||||
// 先只读文件头拿原始尺寸,再决定按多大解码。
|
||||
// 直接全尺寸解码一张 12MP 照片要 48MB 内存,没必要。
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(originalBytes);
|
||||
final descriptor = await ui.ImageDescriptor.encoded(buffer);
|
||||
|
||||
final ratio = descriptor.width > maxWidth ? maxWidth / descriptor.width : 1.0;
|
||||
final outW = (descriptor.width * ratio).round();
|
||||
final outH = (descriptor.height * ratio).round();
|
||||
|
||||
final codec = await descriptor.instantiateCodec(targetWidth: outW, targetHeight: outH);
|
||||
final frame = await codec.getNextFrame();
|
||||
final source = frame.image;
|
||||
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
canvas.drawImage(source, Offset.zero, Paint());
|
||||
paintWatermark(
|
||||
canvas,
|
||||
Size(outW.toDouble(), outH.toDouble()),
|
||||
settings,
|
||||
data,
|
||||
brandIcon: brandIcon,
|
||||
);
|
||||
|
||||
final picture = recorder.endRecording();
|
||||
final rendered = await picture.toImage(outW, outH);
|
||||
|
||||
// 到这里内容已经栅格化,可以把中间对象都放掉
|
||||
source.dispose();
|
||||
picture.dispose();
|
||||
codec.dispose();
|
||||
descriptor.dispose();
|
||||
buffer.dispose();
|
||||
|
||||
final byteData = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
rendered.dispose();
|
||||
if (byteData == null) {
|
||||
throw StateError('水印合成失败:拿不到位图数据');
|
||||
}
|
||||
|
||||
// toByteData 只有 PNG / rawRgba,出不了 JPEG,所以自己用 image 包编码。
|
||||
// 之所以非要 JPEG:1920×1440 的照片 PNG 要 3~6MB,JPEG 只要 ~500KB。
|
||||
// 画面不透明(底图铺满),预乘与非预乘 RGBA 等价,直接当 RGBA 用。
|
||||
final raster = img.Image.fromBytes(
|
||||
width: outW,
|
||||
height: outH,
|
||||
bytes: byteData.buffer,
|
||||
bytesOffset: byteData.offsetInBytes,
|
||||
numChannels: 4,
|
||||
);
|
||||
return img.encodeJpg(raster, quality: quality);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/watermark.dart';
|
||||
|
||||
/// 编辑这一张照片的水印内容(地址 / 天气 / 温度 / 备注)
|
||||
///
|
||||
/// 返回 null 表示用户取消了。时间不在这里改 —— 它总是「现在」,
|
||||
/// 让用户手改反而容易把水印弄成假的时间。
|
||||
Future<WatermarkData?> showWatermarkContentSheet(
|
||||
BuildContext context, {
|
||||
required WatermarkData initial,
|
||||
}) {
|
||||
return showModalBottomSheet<WatermarkData>(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => _WatermarkContentSheet(initial: initial),
|
||||
);
|
||||
}
|
||||
|
||||
class _WatermarkContentSheet extends StatefulWidget {
|
||||
const _WatermarkContentSheet({required this.initial});
|
||||
|
||||
final WatermarkData initial;
|
||||
|
||||
@override
|
||||
State<_WatermarkContentSheet> createState() => _WatermarkContentSheetState();
|
||||
}
|
||||
|
||||
class _WatermarkContentSheetState extends State<_WatermarkContentSheet> {
|
||||
late final TextEditingController _address;
|
||||
late final TextEditingController _weather;
|
||||
late final TextEditingController _temperature;
|
||||
late final TextEditingController _remark;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_address = TextEditingController(text: widget.initial.address);
|
||||
_weather = TextEditingController(text: widget.initial.weather);
|
||||
_temperature = TextEditingController(text: widget.initial.temperature);
|
||||
_remark = TextEditingController(text: widget.initial.remark);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_address.dispose();
|
||||
_weather.dispose();
|
||||
_temperature.dispose();
|
||||
_remark.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
Navigator.of(context).pop(
|
||||
widget.initial.copyWith(
|
||||
address: _address.text.trim(),
|
||||
weather: _weather.text.trim(),
|
||||
temperature: _temperature.text.trim(),
|
||||
remark: _remark.text.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
// 键盘弹出时把内容顶上去,否则输入框会被挡住
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Center(
|
||||
child: Text(
|
||||
'编辑水印内容',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
_field(label: '地址', controller: _address, hint: '自动定位,也可手改'),
|
||||
_field(label: '天气', controller: _weather, hint: '如:晴'),
|
||||
_field(label: '温度', controller: _temperature, hint: '如:25℃'),
|
||||
_field(label: '备注', controller: _remark, hint: '想写点什么'),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 46,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFC8956E),
|
||||
side: const BorderSide(color: Color(0xFFC8956E)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(23),
|
||||
),
|
||||
),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 46,
|
||||
child: FilledButton(
|
||||
onPressed: _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFC8956E),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(23),
|
||||
),
|
||||
),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _field({
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
required String hint,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF7F4F1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF5D4037)),
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 13),
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(fontSize: 14, color: Color(0xFFD7CCC8)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../providers/watermark_provider.dart';
|
||||
|
||||
/// 水印设置(开关 + 文字颜色)
|
||||
///
|
||||
/// 用底部弹层而不是独立页面:编辑页里改完能立刻在预览上看到效果,
|
||||
/// 入口页也能从同一个地方进。
|
||||
Future<void> showWatermarkSettingsSheet(BuildContext context) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => const _WatermarkSettingsSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 可选文字颜色。白色是默认 —— 深色照片上最通用;浅色照片可以换黑色或暖黄。
|
||||
const List<({String label, int value})> _textColors = [
|
||||
(label: '白', value: 0xFFFFFFFF),
|
||||
(label: '黑', value: 0xFF000000),
|
||||
(label: '暖黄', value: 0xFFFFD54F),
|
||||
(label: '品牌黄', value: 0xFFFFB800),
|
||||
(label: '品牌棕', value: 0xFFC8956E),
|
||||
(label: '浅蓝', value: 0xFF4FC3F7),
|
||||
];
|
||||
|
||||
class _WatermarkSettingsSheet extends ConsumerWidget {
|
||||
const _WatermarkSettingsSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(watermarkSettingsProvider);
|
||||
final notifier = ref.read(watermarkSettingsProvider.notifier);
|
||||
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Center(
|
||||
child: Text(
|
||||
'水印设置',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
_sectionTitle('显示内容'),
|
||||
_switchRow(
|
||||
'时间',
|
||||
'如 2026.09.28 14:30',
|
||||
settings.showTime,
|
||||
(v) => notifier.update(settings.copyWith(showTime: v)),
|
||||
),
|
||||
_switchRow(
|
||||
'地点',
|
||||
'自动定位,也可在编辑页手填',
|
||||
settings.showAddress,
|
||||
(v) => notifier.update(settings.copyWith(showAddress: v)),
|
||||
),
|
||||
_switchRow(
|
||||
'天气温度',
|
||||
'如 晴 25℃(拿不到时这一行不显示)',
|
||||
settings.showWeather,
|
||||
(v) => notifier.update(settings.copyWith(showWeather: v)),
|
||||
),
|
||||
_switchRow(
|
||||
'备注',
|
||||
'每张照片单独填',
|
||||
settings.showRemark,
|
||||
(v) => notifier.update(settings.copyWith(showRemark: v)),
|
||||
),
|
||||
_switchRow(
|
||||
'右下角品牌角标',
|
||||
'相机图标 + 水印相机',
|
||||
settings.showBrand,
|
||||
(v) => notifier.update(settings.copyWith(showBrand: v)),
|
||||
),
|
||||
|
||||
const SizedBox(height: 18),
|
||||
_sectionTitle('文字颜色'),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: _textColors.map((c) {
|
||||
final selected = settings.colorValue == c.value;
|
||||
return GestureDetector(
|
||||
onTap: () => notifier.update(settings.copyWith(colorValue: c.value)),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(c.value),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? const Color(0xFFC8956E)
|
||||
: const Color(0xFFE0E0E0),
|
||||
width: selected ? 3 : 1,
|
||||
),
|
||||
),
|
||||
child: selected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
size: 18,
|
||||
color: _isLight(c.value)
|
||||
? const Color(0xFF5D4037)
|
||||
: Colors.white,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
c.label,
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 18),
|
||||
_sectionTitle('水印位置'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3E6),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'位置在编辑页直接拖动水印调整,会自动记住',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFF6D4C41), height: 1.5),
|
||||
),
|
||||
),
|
||||
if (settings.hasCustomPosition)
|
||||
TextButton(
|
||||
onPressed: notifier.resetPosition,
|
||||
child: const Text(
|
||||
'恢复默认',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFFC8956E)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionTitle(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF8D6E63),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _switchRow(String label, String hint, bool value, ValueChanged<bool> onChanged) {
|
||||
return InkWell(
|
||||
onTap: () => onChanged(!value),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037)),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hint,
|
||||
style: const TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(value: value, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 浅色底上的对勾要用深色,不然看不见
|
||||
static bool _isLight(int argb) {
|
||||
final r = (argb >> 16) & 0xFF;
|
||||
final g = (argb >> 8) & 0xFF;
|
||||
final b = argb & 0xFF;
|
||||
return (r * 299 + g * 587 + b * 114) / 1000 > 160;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user