feat: add theme color selection

feat: drag-and-drop hints for signature cards
This commit is contained in:
insleker 2025-09-20 18:37:49 +08:00
parent bc524e958f
commit 8197a352aa
20 changed files with 308 additions and 40 deletions

View File

@ -90,3 +90,7 @@ Some rule of thumb:
* [Viewer Customization using Widget Overlay](https://pub.dev/documentation/pdfrx/latest/pdfrx/PdfViewerParams/viewerOverlayBuilder.html) * [Viewer Customization using Widget Overlay](https://pub.dev/documentation/pdfrx/latest/pdfrx/PdfViewerParams/viewerOverlayBuilder.html)
* [Per-page Customization using Widget Overlay](https://pub.dev/documentation/pdfrx/latest/pdfrx/PdfViewerParams/pageOverlaysBuilder.html) * [Per-page Customization using Widget Overlay](https://pub.dev/documentation/pdfrx/latest/pdfrx/PdfViewerParams/pageOverlaysBuilder.html)
* `pageOverlaysBuilder` * `pageOverlaysBuilder`
* [image](https://pub.dev/packages/image)
* whole app use its image object as image representation.
* aware that minimize, encode/decode usage, because its has poor performance on web
* `ColorFilterGenerator` can not replace `adjustColor` due to custom background removal algorithm need at last stage. It is GPU based, and offscreen-render then readback is not ideal.

View File

@ -39,18 +39,19 @@ class MyApp extends StatelessWidget {
), ),
data: (_) { data: (_) {
final themeMode = ref.watch(themeModeProvider); final themeMode = ref.watch(themeModeProvider);
final seed = ref.watch(themeSeedColorProvider);
final appLocale = ref.watch(localeProvider); final appLocale = ref.watch(localeProvider);
return MaterialApp.router( return MaterialApp.router(
onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle, onGenerateTitle: (ctx) => AppLocalizations.of(ctx).appTitle,
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo, seedColor: seed,
brightness: Brightness.light, brightness: Brightness.light,
), ),
), ),
darkTheme: ThemeData( darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo, seedColor: seed,
brightness: Brightness.dark, brightness: Brightness.dark,
), ),
), ),

View File

@ -28,7 +28,9 @@ Set<String> _supportedTags() {
// Keys // Keys
const _kTheme = 'theme'; // 'light'|'dark'|'system' const _kTheme = 'theme'; // 'light'|'dark'|'system'
const _kThemeColor = 'theme_color'; // 'blue'|'green'|'red'|'purple' // Theme color persisted as hex ARGB string (e.g., '#FF2196F3').
// Backward compatible with historical names like 'blue', 'indigo', etc.
const _kThemeColor = 'theme_color';
const _kLanguage = 'language'; // BCP-47 tag like 'en', 'zh-TW', 'es' const _kLanguage = 'language'; // BCP-47 tag like 'en', 'zh-TW', 'es'
const _kPageView = 'page_view'; // now only 'continuous' const _kPageView = 'page_view'; // now only 'continuous'
const _kExportDpi = 'export_dpi'; // double, allowed: 96,144,200,300 const _kExportDpi = 'export_dpi'; // double, allowed: 96,144,200,300
@ -67,6 +69,54 @@ String _normalizeLanguageTag(String tag) {
class PreferencesStateNotifier extends StateNotifier<PreferencesState> { class PreferencesStateNotifier extends StateNotifier<PreferencesState> {
final SharedPreferences prefs; final SharedPreferences prefs;
static Color? _tryParseColor(String? s) {
if (s == null || s.isEmpty) return null;
final v = s.trim();
// 1) Direct hex formats: #AARRGGBB, #RRGGBB, AARRGGBB, RRGGBB
String hex = v.startsWith('#') ? v.substring(1) : v;
// Accept 0xAARRGGBB / 0xRRGGBB as well
if (hex.toLowerCase().startsWith('0x')) hex = hex.substring(2);
if (hex.length == 6) {
final intVal = int.tryParse('FF$hex', radix: 16);
if (intVal != null) return Color(intVal);
} else if (hex.length == 8) {
final intVal = int.tryParse(hex, radix: 16);
if (intVal != null) return Color(intVal);
}
// 2) Parse from Color(...) or MaterialColor(...) toString outputs
// e.g., 'Color(0xff2196f3)' or 'MaterialColor(primary value: Color(0xff2196f3))'
final lower = v.toLowerCase();
final idx = lower.indexOf('0x');
if (idx != -1) {
var sub = lower.substring(idx);
// Trim trailing non-hex chars
final hexChars = RegExp(r'^[0-9a-fx]+');
final m = hexChars.firstMatch(sub);
if (m != null) {
sub = m.group(0) ?? sub;
if (sub.startsWith('0x')) sub = sub.substring(2);
if (sub.length == 6) sub = 'FF$sub';
if (sub.length >= 8) {
final intVal = int.tryParse(sub.substring(0, 8), radix: 16);
if (intVal != null) return Color(intVal);
}
}
}
// 3) As a last resort, try to match any MaterialColor primary by toString equality
// (useful if some code persisted mat.toString()).
for (final mc in Colors.primaries) {
if (mc.toString() == v) {
return Color(mc.value);
}
}
return null;
}
static String _toHex(Color c) =>
'#${c.value.toRadixString(16).padLeft(8, '0').toUpperCase()}';
PreferencesStateNotifier(this.prefs) PreferencesStateNotifier(this.prefs)
: super( : super(
PreferencesState( PreferencesState(
@ -77,7 +127,7 @@ class PreferencesStateNotifier extends StateNotifier<PreferencesState> {
.toLanguageTag(), .toLanguageTag(),
), ),
exportDpi: _readDpi(prefs), exportDpi: _readDpi(prefs),
theme_color: prefs.getString(_kThemeColor) ?? 'blue', theme_color: prefs.getString(_kThemeColor) ?? '#FF2196F3', // blue
), ),
) { ) {
// normalize language to supported/fallback // normalize language to supported/fallback
@ -108,6 +158,20 @@ class PreferencesStateNotifier extends StateNotifier<PreferencesState> {
state = state.copyWith(exportDpi: 144.0); state = state.copyWith(exportDpi: 144.0);
prefs.setDouble(_kExportDpi, 144.0); prefs.setDouble(_kExportDpi, 144.0);
} }
// Ensure theme color is a valid hex or known name; normalize to hex
final parsed = _tryParseColor(state.theme_color);
if (parsed == null) {
final fallback = Colors.blue;
final hex = _toHex(fallback);
state = state.copyWith(theme_color: hex);
prefs.setString(_kThemeColor, hex);
} else {
final hex = _toHex(parsed);
if (state.theme_color != hex) {
state = state.copyWith(theme_color: hex);
prefs.setString(_kThemeColor, hex);
}
}
} }
Future<void> setTheme(String theme) async { Future<void> setTheme(String theme) async {
@ -123,6 +187,14 @@ class PreferencesStateNotifier extends StateNotifier<PreferencesState> {
await prefs.setString(_kLanguage, normalized); await prefs.setString(_kLanguage, normalized);
} }
Future<void> setThemeColor(String themeColor) async {
// Accept hex like '#FF2196F3', '#2196F3', or known names like 'blue'. Normalize to hex.
final c = _tryParseColor(themeColor) ?? Colors.blue;
final hex = _toHex(c);
state = state.copyWith(theme_color: hex);
await prefs.setString(_kThemeColor, hex);
}
Future<void> resetToDefaults() async { Future<void> resetToDefaults() async {
final device = final device =
WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(); WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag();
@ -131,12 +203,13 @@ class PreferencesStateNotifier extends StateNotifier<PreferencesState> {
theme: 'system', theme: 'system',
language: normalized, language: normalized,
exportDpi: 144.0, exportDpi: 144.0,
theme_color: '', theme_color: '#FF2196F3',
); );
await prefs.setString(_kTheme, 'system'); await prefs.setString(_kTheme, 'system');
await prefs.setString(_kLanguage, normalized); await prefs.setString(_kLanguage, normalized);
await prefs.setString(_kPageView, 'continuous'); await prefs.setString(_kPageView, 'continuous');
await prefs.setDouble(_kExportDpi, 144.0); await prefs.setDouble(_kExportDpi, 144.0);
await prefs.setString(_kThemeColor, '#FF2196F3');
} }
Future<void> setExportDpi(double dpi) async { Future<void> setExportDpi(double dpi) async {
@ -182,6 +255,13 @@ final themeModeProvider = Provider<ThemeMode>((ref) {
} }
}); });
/// Maps the selected theme color name to an actual Color for theming.
final themeSeedColorProvider = Provider<Color>((ref) {
final prefs = ref.watch(preferencesRepositoryProvider);
final c = PreferencesStateNotifier._tryParseColor(prefs.theme_color);
return c ?? Colors.blue;
});
final localeProvider = Provider<Locale?>((ref) { final localeProvider = Provider<Locale?>((ref) {
final prefs = ref.watch(preferencesRepositoryProvider); final prefs = ref.watch(preferencesRepositoryProvider);
final supported = _supportedTags(); final supported = _supportedTags();

View File

@ -25,23 +25,6 @@ class CachedSignatureCard extends SignatureCard {
} }
} }
/// Returns cached processed image for the current [graphicAdjust], computing
/// via [service] if not cached yet.
img.Image getOrComputeProcessedImage(
SignatureImageProcessingService service,
) {
final existing = _cachedProcessedImage;
if (existing != null) {
return existing;
}
final computedImage = service.processImageToImage(
asset.sigImage,
graphicAdjust,
);
_cachedProcessedImage = computedImage;
return computedImage;
}
/// Invalidate the cached processed image, forcing recompute next time. /// Invalidate the cached processed image, forcing recompute next time.
void invalidateCache() { void invalidateCache() {
_cachedProcessedImage = null; _cachedProcessedImage = null;

View File

@ -13,6 +13,7 @@
"display": "Anzeige", "display": "Anzeige",
"downloadStarted": "Download gestartet", "downloadStarted": "Download gestartet",
"dpi": "DPI", "dpi": "DPI",
"dragOntoDocument": "Auf Dokument ziehen",
"drawSignature": "Signatur zeichnen", "drawSignature": "Signatur zeichnen",
"errorWithMessage": "Fehler: {message}", "errorWithMessage": "Fehler: {message}",
"exportingPleaseWait": "Exportiere… Bitte warten", "exportingPleaseWait": "Exportiere… Bitte warten",
@ -47,6 +48,11 @@
"themeDark": "Dunkel", "themeDark": "Dunkel",
"themeLight": "Hell", "themeLight": "Hell",
"themeSystem": "System", "themeSystem": "System",
"themeColor": "Themenfarbe",
"themeColorBlue": "Blau",
"themeColorGreen": "Grün",
"themeColorRed": "Rot",
"themeColorPurple": "Lila",
"undo": "Rückgängig", "undo": "Rückgängig",
"unlock": "Entsperren" "unlock": "Entsperren"
} }

View File

@ -28,6 +28,10 @@
"@downloadStarted": {}, "@downloadStarted": {},
"dpi": "DPI", "dpi": "DPI",
"@dpi": {}, "@dpi": {},
"dragOntoDocument": "Drag onto document",
"@dragOntoDocument": {
"description": "Tooltip message for dragging signature card onto PDF document"
},
"drawSignature": "Draw Signature", "drawSignature": "Draw Signature",
"@drawSignature": {}, "@drawSignature": {},
"errorWithMessage": "Error: {message}", "errorWithMessage": "Error: {message}",
@ -120,6 +124,16 @@
"@themeLight": {}, "@themeLight": {},
"themeSystem": "System", "themeSystem": "System",
"@themeSystem": {}, "@themeSystem": {},
"themeColor": "Theme color",
"@themeColor": {},
"themeColorBlue": "Blue",
"@themeColorBlue": {},
"themeColorGreen": "Green",
"@themeColorGreen": {},
"themeColorRed": "Red",
"@themeColorRed": {},
"themeColorPurple": "Purple",
"@themeColorPurple": {},
"undo": "Undo", "undo": "Undo",
"@undo": {}, "@undo": {},
"unlock": "Unlock", "unlock": "Unlock",

View File

@ -13,6 +13,7 @@
"display": "Pantalla", "display": "Pantalla",
"downloadStarted": "Descarga iniciada", "downloadStarted": "Descarga iniciada",
"dpi": "DPI", "dpi": "DPI",
"dragOntoDocument": "Arrastra sobre el documento",
"drawSignature": "Dibujar firma", "drawSignature": "Dibujar firma",
"errorWithMessage": "Error: {message}", "errorWithMessage": "Error: {message}",
"exportingPleaseWait": "Exportando... Por favor, espere", "exportingPleaseWait": "Exportando... Por favor, espere",
@ -47,6 +48,11 @@
"themeDark": "Oscuro", "themeDark": "Oscuro",
"themeLight": "Claro", "themeLight": "Claro",
"themeSystem": "Sistema", "themeSystem": "Sistema",
"themeColor": "Color del tema",
"themeColorBlue": "Azul",
"themeColorGreen": "Verde",
"themeColorRed": "Rojo",
"themeColorPurple": "Púrpura",
"undo": "Deshacer", "undo": "Deshacer",
"unlock": "Desbloquear" "unlock": "Desbloquear"
} }

View File

@ -13,6 +13,7 @@
"display": "Affichage", "display": "Affichage",
"downloadStarted": "Téléchargement commencé", "downloadStarted": "Téléchargement commencé",
"dpi": "DPI :", "dpi": "DPI :",
"dragOntoDocument": "Faites glisser sur le document",
"drawSignature": "Dessiner une signature", "drawSignature": "Dessiner une signature",
"errorWithMessage": "Erreur : {message}", "errorWithMessage": "Erreur : {message}",
"exportingPleaseWait": "Exportation… Veuillez patienter", "exportingPleaseWait": "Exportation… Veuillez patienter",
@ -47,6 +48,11 @@
"themeDark": "Sombre", "themeDark": "Sombre",
"themeLight": "Clair", "themeLight": "Clair",
"themeSystem": "Système", "themeSystem": "Système",
"themeColor": "Couleur du thème",
"themeColorBlue": "Bleu",
"themeColorGreen": "Vert",
"themeColorRed": "Rouge",
"themeColorPurple": "Violet",
"undo": "Annuler", "undo": "Annuler",
"unlock": "Déverrouiller" "unlock": "Déverrouiller"
} }

View File

@ -13,6 +13,7 @@
"display": "表示", "display": "表示",
"downloadStarted": "ダウンロード開始", "downloadStarted": "ダウンロード開始",
"dpi": "DPI", "dpi": "DPI",
"dragOntoDocument": "ドキュメントにドラッグします",
"drawSignature": "署名をかく", "drawSignature": "署名をかく",
"errorWithMessage": "エラー:{message}", "errorWithMessage": "エラー:{message}",
"exportingPleaseWait": "エクスポート中…お待ちください", "exportingPleaseWait": "エクスポート中…お待ちください",
@ -47,6 +48,11 @@
"themeDark": "ダーク", "themeDark": "ダーク",
"themeLight": "ライト", "themeLight": "ライト",
"themeSystem": "システム", "themeSystem": "システム",
"themeColor": "テーマカラー",
"themeColorBlue": "青",
"themeColorGreen": "緑",
"themeColorRed": "赤",
"themeColorPurple": "紫",
"undo": "元に戻す", "undo": "元に戻す",
"unlock": "ロック解除" "unlock": "ロック解除"
} }

View File

@ -13,6 +13,7 @@
"display": "표시", "display": "표시",
"downloadStarted": "다운로드 시작됨", "downloadStarted": "다운로드 시작됨",
"dpi": "DPI", "dpi": "DPI",
"dragOntoDocument": "문서로 끌어다 놓습니다",
"drawSignature": "서명 그리기", "drawSignature": "서명 그리기",
"errorWithMessage": "오류: {message}", "errorWithMessage": "오류: {message}",
"exportingPleaseWait": "내보내는 중... 잠시 기다려주세요", "exportingPleaseWait": "내보내는 중... 잠시 기다려주세요",
@ -47,6 +48,11 @@
"themeDark": "다크", "themeDark": "다크",
"themeLight": "라이트", "themeLight": "라이트",
"themeSystem": "시스템", "themeSystem": "시스템",
"themeColor": "테마 색상",
"themeColorBlue": "파란색",
"themeColorGreen": "녹색",
"themeColorRed": "빨간색",
"themeColorPurple": "보라색",
"undo": "실행 취소", "undo": "실행 취소",
"unlock": "잠금 해제" "unlock": "잠금 해제"
} }

View File

@ -13,6 +13,7 @@
"display": "Відображення", "display": "Відображення",
"downloadStarted": "Завантаження розпочато", "downloadStarted": "Завантаження розпочато",
"dpi": "DPI", "dpi": "DPI",
"dragOntoDocument": "Перетягніть на документ",
"drawSignature": "Намалювати підпис", "drawSignature": "Намалювати підпис",
"errorWithMessage": "Помилка: {message}", "errorWithMessage": "Помилка: {message}",
"exportingPleaseWait": "Експортування... Зачекайте", "exportingPleaseWait": "Експортування... Зачекайте",
@ -47,6 +48,11 @@
"themeDark": "Темна", "themeDark": "Темна",
"themeLight": "Світла", "themeLight": "Світла",
"themeSystem": "Системна", "themeSystem": "Системна",
"themeColor": "Колір теми",
"themeColorBlue": "Синій",
"themeColorGreen": "Зелений",
"themeColorRed": "Червоний",
"themeColorPurple": "Фіолетовий",
"undo": "Відмінити", "undo": "Відмінити",
"unlock": "Відмкнути" "unlock": "Відмкнути"
} }

View File

@ -14,6 +14,7 @@
"display": "顯示", "display": "顯示",
"downloadStarted": "已開始下載", "downloadStarted": "已開始下載",
"dpi": "DPI", "dpi": "DPI",
"dragOntoDocument": "拖到文档上",
"drawSignature": "手寫簽名", "drawSignature": "手寫簽名",
"errorWithMessage": "錯誤:{message}", "errorWithMessage": "錯誤:{message}",
"exportingPleaseWait": "匯出中…請稍候", "exportingPleaseWait": "匯出中…請稍候",
@ -48,6 +49,11 @@
"themeDark": "深色", "themeDark": "深色",
"themeLight": "淺色", "themeLight": "淺色",
"themeSystem": "系統", "themeSystem": "系統",
"themeColor": "主题颜色",
"themeColorBlue": "蓝色",
"themeColorGreen": "绿色",
"themeColorRed": "红色",
"themeColorPurple": "紫色",
"undo": "復原", "undo": "復原",
"unlock": "解锁" "unlock": "解锁"
} }

View File

@ -13,6 +13,7 @@
"display": "显示", "display": "显示",
"downloadStarted": "下载已开始", "downloadStarted": "下载已开始",
"dpi": "DPI", "dpi": "DPI",
"dragOntoDocument": "拖到文档上",
"drawSignature": "绘制签名", "drawSignature": "绘制签名",
"errorWithMessage": "错误:{message}", "errorWithMessage": "错误:{message}",
"exportingPleaseWait": "正在导出... 请稍候", "exportingPleaseWait": "正在导出... 请稍候",
@ -47,6 +48,11 @@
"themeDark": "深色", "themeDark": "深色",
"themeLight": "浅色", "themeLight": "浅色",
"themeSystem": "系统", "themeSystem": "系统",
"themeColor": "主题颜色",
"themeColorBlue": "蓝色",
"themeColorGreen": "绿色",
"themeColorRed": "红色",
"themeColorPurple": "紫色",
"undo": "撤销", "undo": "撤销",
"unlock": "解锁" "unlock": "解锁"
} }

View File

@ -14,6 +14,7 @@
"display": "顯示", "display": "顯示",
"downloadStarted": "已開始下載", "downloadStarted": "已開始下載",
"dpi": "DPI", "dpi": "DPI",
"dragOntoDocument": "拖曳到文件",
"drawSignature": "手寫簽名", "drawSignature": "手寫簽名",
"errorWithMessage": "錯誤:{message}", "errorWithMessage": "錯誤:{message}",
"exportingPleaseWait": "匯出中…請稍候", "exportingPleaseWait": "匯出中…請稍候",
@ -48,6 +49,11 @@
"themeDark": "深色", "themeDark": "深色",
"themeLight": "淺色", "themeLight": "淺色",
"themeSystem": "系統", "themeSystem": "系統",
"themeColor": "主題顏色",
"themeColorBlue": "藍色",
"themeColorGreen": "綠色",
"themeColorRed": "紅色",
"themeColorPurple": "紫色",
"undo": "復原", "undo": "復原",
"unlock": "解鎖" "unlock": "解鎖"
} }

View File

@ -10,7 +10,6 @@ class DrawCanvas extends StatefulWidget {
this.control, this.control,
this.onConfirm, this.onConfirm,
this.debugBytesSink, this.debugBytesSink,
this.closeOnConfirmImmediately = false,
}); });
final hand.HandSignatureControl? control; final hand.HandSignatureControl? control;
@ -18,9 +17,6 @@ class DrawCanvas extends StatefulWidget {
// For tests: allows observing exported bytes without relying on Navigator // For tests: allows observing exported bytes without relying on Navigator
@visibleForTesting @visibleForTesting
final ValueNotifier<Uint8List?>? debugBytesSink; final ValueNotifier<Uint8List?>? debugBytesSink;
// When true (used by bottom sheet), the sheet will be closed immediately
// on confirm without waiting for export to finish.
final bool closeOnConfirmImmediately;
@override @override
State<DrawCanvas> createState() => _DrawCanvasState(); State<DrawCanvas> createState() => _DrawCanvasState();

View File

@ -98,6 +98,17 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
if (controller.isReady) controller.goToPage(pageNumber: target); if (controller.isReady) controller.goToPage(pageNumber: target);
} }
img.Image? _toStdSignatureImage(img.Image? image) {
if (image == null) return null;
image.convert(numChannels: 4);
// Scale down if height > 256 to improve performance
if (image.height > 256) {
final newWidth = (image.width * 256) ~/ image.height;
image = img.copyResize(image, width: newWidth, height: 256);
}
return image;
}
Future<img.Image?> _loadSignatureFromFile() async { Future<img.Image?> _loadSignatureFromFile() async {
final typeGroup = fs.XTypeGroup( final typeGroup = fs.XTypeGroup(
label: label:
@ -109,8 +120,7 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
final bytes = await file.readAsBytes(); final bytes = await file.readAsBytes();
try { try {
var sigImage = img.decodeImage(bytes); var sigImage = img.decodeImage(bytes);
sigImage?.convert(numChannels: 4); return _toStdSignatureImage(sigImage);
return sigImage;
} catch (_) { } catch (_) {
return null; return null;
} }
@ -121,14 +131,13 @@ class _PdfSignatureHomePageState extends ConsumerState<PdfSignatureHomePage> {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
enableDrag: false, enableDrag: false,
builder: (_) => const DrawCanvas(closeOnConfirmImmediately: false), builder: (_) => const DrawCanvas(),
); );
if (result == null || result.isEmpty) return null; if (result == null || result.isEmpty) return null;
// In simplified UI, adding to library isn't implemented // In simplified UI, adding to library isn't implemented
try { try {
var sigImage = img.decodeImage(result); var sigImage = img.decodeImage(result);
sigImage?.convert(numChannels: 4); return _toStdSignatureImage(sigImage);
return sigImage;
} catch (_) { } catch (_) {
return null; return null;
} }

View File

@ -12,6 +12,7 @@ class SettingsDialog extends ConsumerStatefulWidget {
class _SettingsDialogState extends ConsumerState<SettingsDialog> { class _SettingsDialogState extends ConsumerState<SettingsDialog> {
String? _theme; String? _theme;
String? _themeColor;
String? _language; String? _language;
// Page view removed; continuous-only // Page view removed; continuous-only
double? _exportDpi; double? _exportDpi;
@ -21,6 +22,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
super.initState(); super.initState();
final prefs = ref.read(preferencesRepositoryProvider); final prefs = ref.read(preferencesRepositoryProvider);
_theme = prefs.theme; _theme = prefs.theme;
_themeColor = prefs.theme_color;
_language = prefs.language; _language = prefs.language;
_exportDpi = prefs.exportDpi; _exportDpi = prefs.exportDpi;
// pageView no longer configurable (continuous-only) // pageView no longer configurable (continuous-only)
@ -174,6 +176,22 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
), ),
], ],
), ),
const SizedBox(height: 8),
Row(
children: [
SizedBox(width: 140, child: Text('${l.themeColor}:')),
const SizedBox(width: 8),
_ThemeColorCircle(
onPick: (value) async {
if (value == null) return;
await ref
.read(preferencesRepositoryProvider.notifier)
.setThemeColor(value);
setState(() => _themeColor = value);
},
),
],
),
const SizedBox(height: 24), const SizedBox(height: 24),
Row( Row(
@ -190,6 +208,8 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
preferencesRepositoryProvider.notifier, preferencesRepositoryProvider.notifier,
); );
if (_theme != null) await n.setTheme(_theme!); if (_theme != null) await n.setTheme(_theme!);
if (_themeColor != null)
await n.setThemeColor(_themeColor!);
if (_language != null) await n.setLanguage(_language!); if (_language != null) await n.setLanguage(_language!);
if (_exportDpi != null) await n.setExportDpi(_exportDpi!); if (_exportDpi != null) await n.setExportDpi(_exportDpi!);
// pageView not configurable anymore // pageView not configurable anymore
@ -206,3 +226,90 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
); );
} }
} }
class _ColorDot extends StatelessWidget {
final Color color;
final double size;
const _ColorDot({required this.color, this.size = 14});
@override
Widget build(BuildContext context) => Container(
width: size,
height: size,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Theme.of(context).dividerColor),
),
);
}
class _ThemeColorCircle extends ConsumerWidget {
final ValueChanged<String?> onPick;
const _ThemeColorCircle({required this.onPick});
@override
Widget build(BuildContext context, WidgetRef ref) {
final seed = ref.watch(themeSeedColorProvider);
return InkWell(
key: const Key('btn_theme_color_picker'),
onTap: () async {
final picked = await showDialog<String>(
context: context,
builder: (ctx) => _ThemeColorPickerDialog(currentColor: seed),
);
onPick(picked);
},
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(6.0),
child: _ColorDot(color: seed, size: 22),
),
);
}
}
class _ThemeColorPickerDialog extends StatelessWidget {
final Color currentColor;
const _ThemeColorPickerDialog({required this.currentColor});
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return AlertDialog(
title: Text(l.themeColor),
content: SizedBox(
width: 320,
child: Wrap(
spacing: 12,
runSpacing: 12,
children: Colors.primaries.map((mat) {
final c = Color(mat.value);
final selected = c.value == currentColor.value;
// Store as ARGB hex string, e.g., #FF2196F3
String hex(Color color) =>
'#${color.value.toRadixString(16).padLeft(8, '0').toUpperCase()}';
return InkWell(
key: Key('pick_${mat.value}'),
onTap: () => Navigator.of(context).pop(hex(c)),
customBorder: const CircleBorder(),
child: Stack(
alignment: Alignment.center,
children: [
_ColorDot(color: c, size: 32),
if (selected)
const Icon(Icons.check, color: Colors.white, size: 20),
],
),
);
}).toList(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: Text(l.cancel),
),
],
);
}
}

View File

@ -106,6 +106,16 @@ class _SignatureCardViewState extends ConsumerState<SignatureCardView> {
), ),
), ),
), ),
// Subtle drag affordance icon
Positioned(
left: 4,
bottom: 4,
child: Icon(
Icons.open_with,
size: 14,
color: Theme.of(context).hintColor.withValues(alpha: 0.9),
),
),
Positioned( Positioned(
right: 0, right: 0,
top: 0, top: 0,
@ -137,7 +147,9 @@ class _SignatureCardViewState extends ConsumerState<SignatureCardView> {
child: child, child: child,
); );
if (widget.disabled) return child; if (widget.disabled) return child;
return Draggable<SignatureDragData>( final isDragging = ref.watch(isDraggingSignatureViewModelProvider);
// Mouse cursor + tooltip + semantics to hint drag behavior
final draggable = Draggable<SignatureDragData>(
data: SignatureDragData( data: SignatureDragData(
card: domain.SignatureCard( card: domain.SignatureCard(
asset: widget.asset, asset: widget.asset,
@ -187,5 +199,17 @@ class _SignatureCardViewState extends ConsumerState<SignatureCardView> {
childWhenDragging: Opacity(opacity: 0.5, child: child), childWhenDragging: Opacity(opacity: 0.5, child: child),
child: child, child: child,
); );
return MouseRegion(
cursor:
isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.grab,
child: Tooltip(
message: AppLocalizations.of(context).dragOntoDocument,
child: Semantics(
label: 'Signature card',
hint: 'Drag onto document to place',
child: draggable,
),
),
);
} }
} }

View File

@ -10,7 +10,7 @@ import 'package:pdf_signature/domain/models/signature_asset.dart';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'image_editor_dialog.dart'; import 'image_editor_dialog.dart';
import 'signature_card_view.dart'; import 'signature_card_view.dart';
import '../../pdf/view_model/pdf_view_model.dart'; // Removed PdfViewModel import; no direct interaction from drawer on tap
/// Data for drag-and-drop is in signature_drag_data.dart /// Data for drag-and-drop is in signature_drag_data.dart
@ -78,13 +78,6 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
.update(card, result.rotation, result.graphicAdjust); .update(card, result.rotation, result.graphicAdjust);
} }
}, },
onTap: () {
// Activate a default overlay rectangle on the current page
// so integration tests can find and size the active overlay.
ref
.read(pdfViewModelProvider.notifier)
.activeRect = const Rect.fromLTWH(0.2, 0.2, 0.3, 0.15);
},
), ),
), ),
), ),

View File

@ -5,6 +5,9 @@ import 'package:image/image.dart' as img;
/// - Ensures the image has an alpha channel (RGBA) before modification. /// - Ensures the image has an alpha channel (RGBA) before modification.
/// - Returns a new img.Image instance; does not mutate the input reference. /// - Returns a new img.Image instance; does not mutate the input reference.
/// - threshold: 0..255; pixels with r,g,b >= threshold become fully transparent. /// - threshold: 0..255; pixels with r,g,b >= threshold become fully transparent.
///
/// TODO: optimize through SIMD or web-ffi openCV, sadly they are not stable yet.
///
img.Image removeNearWhiteBackground(img.Image image, {int threshold = 240}) { img.Image removeNearWhiteBackground(img.Image image, {int threshold = 240}) {
// Ensure truecolor RGBA; paletted images won't apply per-pixel alpha properly. // Ensure truecolor RGBA; paletted images won't apply per-pixel alpha properly.
final hadAlpha = image.hasAlpha; final hadAlpha = image.hasAlpha;