水印相机前端

This commit is contained in:
2026-09-28 09:59:43 +08:00
parent 187a7e0911
commit c5b3d81d07
19 changed files with 2761 additions and 3 deletions
+247
View File
@@ -0,0 +1,247 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import '../core/network/api_exception.dart';
/// 定位 + 逆地理编码 + 天气。
///
/// **注意这里直连外部服务,不走 [dioProvider]** —— 那个 Dio 带着项目的 baseUrl
/// 和 Authorization 拦截器,会把我们的 JWT 一起发到百度/Open-Meteo 去。
/// 所以本类自己持有一个干净的 Dio。
///
/// AK 说明:沿用原「水印相机」项目的百度服务端 AK(用户已确认个人项目可接受
/// 硬编码在客户端)。风险只有一个:AK 被刷爆后逆地理会失效,水印仍能正常合成。
/// 百度地图开放平台 AK(服务端类型,需勾选逆地理编码权限)
const String _baiduAk = 'I3IYR0glCNfmfYzMoBDr7lLTKdZ8Y0Xs';
const String _baiduReverseUrl = 'https://api.map.baidu.com/reverse_geocoding/v3/';
const String _openMeteoUrl = 'https://api.open-meteo.com/v1/forecast';
final geoApiProvider = Provider<GeoApi>((ref) => GeoApi());
/// 一次定位拿到的全部信息
class GeoSnapshot {
const GeoSnapshot({
this.latitude,
this.longitude,
this.address = '',
this.weather = '',
this.temperature = '',
this.error,
});
final double? latitude;
final double? longitude;
final String address;
final String weather;
final String temperature;
/// 定位失败时的原因(地址/天气会留空,页面据此提示用户)
final String? error;
bool get hasPosition => latitude != null && longitude != null;
}
class GeoApi {
GeoApi({Dio? dio})
: _dio = dio ??
Dio(BaseOptions(
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 10),
));
final Dio _dio;
/// 取当前位置(WGS84)。
///
/// 位置本身失败会抛 [ApiException](权限没给 / 定位服务没开),
/// 由调用方决定怎么提示。
Future<Position> currentPosition() async {
if (!await Geolocator.isLocationServiceEnabled()) {
throw ApiException(-1, '定位服务未开启,请到系统设置里打开');
}
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.deniedForever) {
throw ApiException(-1, '定位权限已被永久拒绝,请到系统设置里开启');
}
if (permission == LocationPermission.denied) {
throw ApiException(-1, '没有定位权限');
}
try {
return await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
timeLimit: Duration(seconds: 15),
),
);
} on Exception catch (e) {
throw ApiException(-1, '定位失败:${e.toString()}');
}
}
/// 经纬度 → 文字地址。
Future<String> reverseAddress({
required double latitude,
required double longitude,
}) async {
final Response<dynamic> res;
try {
res = await _dio.get<dynamic>(
_baiduReverseUrl,
queryParameters: {
'ak': _baiduAk,
'output': 'json',
// geolocator 给的是 WGS84 原始 GPS 坐标,必须显式告诉百度,
// 否则地址会有几百米偏移
'coordtype': 'wgs84ll',
'location': '$latitude,$longitude',
'extensions_poi': 0,
},
);
} on DioException catch (e) {
throw ApiException(-1, '地址解析失败:${e.message ?? '网络异常'}');
}
final data = res.data;
if (data is! Map) {
throw ApiException(-1, '地址解析失败:返回格式异常');
}
final status = data['status'];
if (status != 0) {
throw ApiException(-1, '地址解析失败:${data['msg'] ?? '百度返回 $status'}');
}
final result = data['result'];
if (result is! Map) {
throw ApiException(-1, '地址解析失败:没有结果');
}
final formatted = result['formatted_address']?.toString() ?? '';
if (formatted.isNotEmpty) return formatted;
// 兜底:拼 addressComponent
final ac = result['addressComponent'];
if (ac is Map) {
final joined = '${ac['city'] ?? ''}${ac['district'] ?? ''}'
'${ac['street'] ?? ''}${ac['street_number'] ?? ''}';
if (joined.isNotEmpty) return joined;
}
throw ApiException(-1, '地址解析失败:结果为空');
}
/// 经纬度 → 实时天气。
///
/// 拿不到就返回 null —— 天气是锦上添花,**不该因为它失败就让整张照片没法出**。
Future<({String weather, String temperature})?> weather({
required double latitude,
required double longitude,
}) async {
try {
final res = await _dio.get<dynamic>(
_openMeteoUrl,
queryParameters: {
'latitude': latitude,
'longitude': longitude,
'current_weather': true,
'timezone': 'auto',
},
);
final data = res.data;
if (data is! Map) return null;
if (data['error'] == true) return null;
final current = data['current_weather'];
if (current is! Map) return null;
final temp = current['temperature'];
return (
weather: _weatherText(current['weathercode']),
temperature: temp is num ? '${temp.round()}℃' : '',
);
} catch (_) {
return null;
}
}
/// 一把梭:定位 → 地址 → 天气。
///
/// 定位失败不抛异常,而是返回带 [GeoSnapshot.error] 的结果 —— 用户可能只是
/// 不想给定位权限,那也该能继续拍照,水印少一行地点而已。
Future<GeoSnapshot> resolve() async {
final Position position;
try {
position = await currentPosition();
} on ApiException catch (e) {
return GeoSnapshot(error: e.message);
}
// 地址拿不到就退回「纬度, 经度」,至少信息量不为零(原项目也这么做)
var address = '${position.latitude.toStringAsFixed(4)}, '
'${position.longitude.toStringAsFixed(4)}';
try {
address = await reverseAddress(
latitude: position.latitude,
longitude: position.longitude,
);
} catch (_) {
// 保持坐标兜底
}
final weather = await this.weather(
latitude: position.latitude,
longitude: position.longitude,
) ??
(weather: '', temperature: '');
return GeoSnapshot(
latitude: position.latitude,
longitude: position.longitude,
address: address,
weather: weather.weather,
temperature: weather.temperature,
);
}
/// Open-Meteo 的 WMO 天气码 → 中文
static String _weatherText(Object? code) {
if (code is! num) return '';
return _weatherCodes[code.toInt()] ?? '未知天气';
}
}
const Map<int, String> _weatherCodes = {
0: '晴空',
1: '大部晴朗',
2: '多云',
3: '阴天',
45: '雾',
48: '霜雾',
51: '细雨',
53: '中细雨',
55: '密细雨',
56: '冻细雨',
57: '强冻细雨',
61: '小雨',
63: '中雨',
65: '大雨',
66: '轻冻雨',
67: '强冻雨',
71: '小雪',
73: '中雪',
75: '大雪',
77: '雪粒',
80: '小阵雨',
81: '中阵雨',
82: '强阵雨',
85: '小阵雪',
86: '大阵雪',
95: '雷阵雨',
96: '雷阵雨伴冰雹',
99: '强雷阵雨伴大冰雹',
};
+74
View File
@@ -0,0 +1,74 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
final photoApiProvider = Provider<PhotoApi>((ref) => PhotoApi(ref.watch(dioProvider)));
/// 照片存档接口。
///
/// App 拍完照把**原图**传上来;水印是本地合成的,和这里无关。
/// 上传是「锦上添花」—— 失败了不该影响用户继续编辑和保存照片,
/// 所以调用方要自己吞掉异常并给个「重试」入口。
class PhotoApi {
PhotoApi(this._dio);
final Dio _dio;
/// 上传一张照片,返回后端记录 id
Future<int?> upload({
required String filePath,
double? latitude,
double? longitude,
String? address,
String? weather,
String? remark,
DateTime? takenTime,
}) async {
try {
final form = FormData.fromMap({
'file': await MultipartFile.fromFile(
filePath,
filename: _fileName(filePath),
),
'latitude': ?latitude,
'longitude': ?longitude,
if (address != null && address.isNotEmpty) 'address': address,
if (weather != null && weather.isNotEmpty) 'weather': weather,
if (remark != null && remark.isNotEmpty) 'remark': remark,
if (takenTime != null) 'takenTime': _formatTime(takenTime),
});
final res = await _dio.post<dynamic>(
'/photos',
data: form,
// 原图可能有好几 MB,移动网络下给宽一点
options: Options(receiveTimeout: const Duration(seconds: 60)),
);
final data = res.data;
if (data is Map) {
final id = data['id'];
if (id is int) return id;
}
return null;
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '照片上传失败');
}
}
/// 从路径里取个带扩展名的文件名,后端靠它判断文件类型
static String _fileName(String path) {
final normalized = path.replaceAll('\\', '/');
final name = normalized.substring(normalized.lastIndexOf('/') + 1);
return name.isEmpty ? 'photo.jpg' : name;
}
static String _formatTime(DateTime time) {
String two(int n) => n.toString().padLeft(2, '0');
return '${time.year}-${two(time.month)}-${two(time.day)} '
'${two(time.hour)}:${two(time.minute)}:${two(time.second)}';
}
}
+8
View File
@@ -4,6 +4,7 @@ import '../../pages/bill/bill_home_page.dart';
import '../../pages/bill/widgets/bill_module_summary.dart';
import '../../pages/note/note_list_page.dart';
import '../../pages/note/widgets/note_module_summary.dart';
import '../../pages/watermark/watermark_camera_page.dart';
/// 一个功能模块要对外暴露的东西:它是谁、怎么打开自己。
///
@@ -65,4 +66,11 @@ final List<AppModule> appModules = [
open: (_) => const NoteListPage(),
summary: (_) => const NoteModuleSummary(),
),
AppModule(
id: 'watermark',
name: '水印相机',
blurb: '拍照自动加时间地点水印',
icon: 'camera',
open: (_) => const WatermarkCameraPage(),
),
];
+20
View File
@@ -70,4 +70,24 @@ class LocalStorage {
if (username == null || password == null) return null;
return (username: username, password: password);
}
// ---- 水印设置 ----
// 存的是 WatermarkSettings.toJson() 的结果。读取失败/没存过都返回 null,
// 由调用方回落到默认设置(见 models/watermark.dart 的 WatermarkSettings.defaults)。
static const _keyWatermarkSettings = 'watermark_settings';
Future<void> saveWatermarkSettings(Map<String, dynamic> json) async {
final prefs = await _getPrefs();
await prefs.setString(_keyWatermarkSettings, jsonEncode(json));
}
Map<String, dynamic>? getWatermarkSettings() {
final raw = _prefs?.getString(_keyWatermarkSettings);
if (raw == null) return null;
try {
return jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return null;
}
}
}
+208
View File
@@ -0,0 +1,208 @@
/// 水印相机模块的数据模型
///
/// 拆成两个类是为了区分「**怎么显示**」和「**显示什么**」:
/// · [WatermarkSettings] —— 全局设置,持久化,很久才改一次(开关/颜色/位置)
/// · [WatermarkData] —— 每张照片都不一样(拍摄时间/地址/天气/备注)
///
/// 绘制入口见 `pages/watermark/watermark_painter.dart`。
library;
/// 水印设置(全局共享,存 LocalStorage)
class WatermarkSettings {
const WatermarkSettings({
this.showTime = true,
this.showAddress = true,
this.showWeather = false,
this.showRemark = false,
this.showBrand = true,
this.colorValue = 0xFFFFFFFF,
this.posX,
this.posY,
});
/// 默认:时间 + 地点 + 品牌角标(和原「水印相机」的默认观感一致)
static const defaults = WatermarkSettings();
final bool showTime;
final bool showAddress;
final bool showWeather;
final bool showRemark;
/// 右下角「相机图标 + 水印相机」角标
final bool showBrand;
/// 文字颜色(ARGB)。默认白色 —— 深色照片上最通用。
final int colorValue;
/// 水印块的**归一化左上角坐标**(0~1,相对于画布宽高)。
///
/// 为 null 表示用默认位置(左下角)。存归一化而不是像素,是为了让预览和导出
/// 天然一致,也让同一份设置在不同宽高比的照片上都能用。
final double? posX;
final double? posY;
/// 有没有任何一行正文要显示(不算品牌角标)
bool get hasTextContent => showTime || showAddress || showWeather || showRemark;
/// 是否已经手动拖过位置
bool get hasCustomPosition => posX != null && posY != null;
static const _sentinel = Object();
WatermarkSettings copyWith({
bool? showTime,
bool? showAddress,
bool? showWeather,
bool? showRemark,
bool? showBrand,
int? colorValue,
// 用 sentinel 兜住「显式置回 null」(重置位置要用)
Object? posX = _sentinel,
Object? posY = _sentinel,
}) {
return WatermarkSettings(
showTime: showTime ?? this.showTime,
showAddress: showAddress ?? this.showAddress,
showWeather: showWeather ?? this.showWeather,
showRemark: showRemark ?? this.showRemark,
showBrand: showBrand ?? this.showBrand,
colorValue: colorValue ?? this.colorValue,
posX: posX == _sentinel ? this.posX : posX as double?,
posY: posY == _sentinel ? this.posY : posY as double?,
);
}
Map<String, dynamic> toJson() => {
'showTime': showTime,
'showAddress': showAddress,
'showWeather': showWeather,
'showRemark': showRemark,
'showBrand': showBrand,
'colorValue': colorValue,
'posX': posX,
'posY': posY,
};
factory WatermarkSettings.fromJson(Map<String, dynamic> json) {
return WatermarkSettings(
showTime: _asBool(json['showTime'], true),
showAddress: _asBool(json['showAddress'], true),
showWeather: _asBool(json['showWeather'], false),
showRemark: _asBool(json['showRemark'], false),
showBrand: _asBool(json['showBrand'], true),
colorValue: _asInt(json['colorValue'], 0xFFFFFFFF),
posX: _asDoubleOrNull(json['posX']),
posY: _asDoubleOrNull(json['posY']),
);
}
static bool _asBool(Object? v, bool fallback) => v is bool ? v : fallback;
static int _asInt(Object? v, int fallback) {
if (v is int) return v;
if (v is num) return v.toInt();
return fallback;
}
static double? _asDoubleOrNull(Object? v) {
if (v is num) return v.toDouble();
return null;
}
}
/// 这一张照片要显示的内容
class WatermarkData {
const WatermarkData({
required this.time,
this.address = '',
this.weather = '',
this.temperature = '',
this.remark = '',
});
/// 拍摄时间(水印上只用到「分」)
final DateTime time;
final String address;
/// 天气描述,如「晴」
final String weather;
/// 温度,如「25℃」
final String temperature;
final String remark;
/// 天气行是否可显示(天气和温度都拿不到时不显示这一行)
bool get hasWeather => weather.isNotEmpty || temperature.isNotEmpty;
WatermarkData copyWith({
DateTime? time,
String? address,
String? weather,
String? temperature,
String? remark,
}) {
return WatermarkData(
time: time ?? this.time,
address: address ?? this.address,
weather: weather ?? this.weather,
temperature: temperature ?? this.temperature,
remark: remark ?? this.remark,
);
}
}
/// 时间格式化成水印上的样子:`2026.09.28 14:30`
///
/// 纯函数,方便单测。用两位补零,月/日/时/分都对齐。
String formatWatermarkTime(DateTime time) {
String two(int n) => n.toString().padLeft(2, '0');
return '${time.year}.${two(time.month)}.${two(time.day)} '
'${two(time.hour)}:${two(time.minute)}';
}
/// 水印正文的一行
class WatermarkLine {
const WatermarkLine(this.text, {this.wrap = true});
final String text;
/// 是否允许折行。
///
/// 时间行**不折**:它是定长字符串(「时间:2026.09.28 14:30」正好 19 个字),
/// 按字数硬折会把最后一个数字甩到单独一行。
/// 这不是猜的 —— 是在导出预览图上真看到了一行只有一个「0」才发现的。
final bool wrap;
}
/// 把设置和数据拼成水印正文的每一行
///
/// 顺序固定为 时间 → 天气 → 地点 → 备注(和原项目一致)。
/// 关掉的项目直接不产生行;天气/备注没内容时也不占一行。
List<WatermarkLine> buildWatermarkLines(WatermarkSettings settings, WatermarkData data) {
final lines = <WatermarkLine>[];
if (settings.showTime) {
lines.add(WatermarkLine(
'时间:${formatWatermarkTime(data.time)}',
wrap: false,
));
}
if (settings.showWeather && data.hasWeather) {
lines.add(WatermarkLine('天气:${'${data.weather} ${data.temperature}'.trim()}'));
}
if (settings.showAddress) {
// 地址拿不到时给个占位,而不是留个「地点:」的空壳
final address = data.address.isEmpty ? '未知地点' : data.address;
lines.add(WatermarkLine('地点:$address'));
}
if (settings.showRemark && data.remark.trim().isNotEmpty) {
lines.add(WatermarkLine('备注:${data.remark.trim()}'));
}
return lines;
}
@@ -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)),
),
],
),
),
),
);
}
}
+370
View File
@@ -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;
}
}
+66
View File
@@ -0,0 +1,66 @@
import 'dart:ui' as ui;
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/storage/local_storage.dart';
import '../models/watermark.dart';
/// 水印设置(全局共享,改动即落盘)
///
/// 跟项目里其他 provider 不同的一点:这个**不涉及网络**,纯粹是设备本地的偏好,
/// 所以直接读写 [LocalStorage],没有 api / 没有 refresh 失效那一套。
///
/// 需要 `LocalStorage.instance.init()` 已经跑过(`main.dart` 启动时做了)。
class WatermarkSettingsNotifier extends Notifier<WatermarkSettings> {
@override
WatermarkSettings build() {
final raw = LocalStorage.instance.getWatermarkSettings();
if (raw == null) return WatermarkSettings.defaults;
// 存坏了就当没存过,用默认值兜住 —— 不能让一段脏 JSON 把页面打不开
try {
return WatermarkSettings.fromJson(raw);
} catch (_) {
return WatermarkSettings.defaults;
}
}
/// 整体替换并落盘。设置页里改开关/颜色走这个。
void update(WatermarkSettings next) {
state = next;
LocalStorage.instance.saveWatermarkSettings(next.toJson());
}
/// 拖动过程中更新位置 —— **只改内存,不落盘**。
/// 拖一次会产生几十上百个事件,每个都写 SharedPreferences 太浪费。
void updatePosition(double x, double y) {
state = state.copyWith(posX: x, posY: y);
}
/// 松手时落盘。
void persistPosition() {
LocalStorage.instance.saveWatermarkSettings(state.toJson());
}
/// 回到默认位置(左下角)
void resetPosition() {
update(state.copyWith(posX: null, posY: null));
}
}
final watermarkSettingsProvider =
NotifierProvider<WatermarkSettingsNotifier, WatermarkSettings>(
WatermarkSettingsNotifier.new,
);
/// 品牌角标用的相机图标(原「水印相机」项目那张,白色描边 + 透明底)。
///
/// 画布要的是 `ui.Image`,不是 widget 用的 `AssetImage`,所以这里自己解码一次。
/// 不加 autoDispose:就一张小图,加载一次全程复用。
final brandIconProvider = FutureProvider<ui.Image>((ref) async {
final data = await rootBundle.load('assets/images/watermark_camera.png');
final codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
final frame = await codec.getNextFrame();
codec.dispose();
return frame.image;
});