import 'dart:convert'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../engine/stroke_geometry.dart' show kDefaultPenThinning; /// Action that can be triggered by a hardware pen button or the eraser end. enum PenButtonAction { none, eraser, undo, toggleTool, pan, } /// Immutable configuration for pen input behaviour. /// /// Persisted under SharedPreferences key [PenConfigController.prefsKey]. class PenConfig { const PenConfig({ this.sideButton = PenButtonAction.eraser, this.eraserEnd = PenButtonAction.eraser, this.pressureGamma = 1.0, this.palmRejectionMs = 150.0, this.fingerDrawing = false, this.penWidth = 0.004, this.highlighterWidth = 0.02, this.pressureSensitivity = kDefaultPenThinning, }) : assert(pressureGamma >= 0.3 && pressureGamma <= 3.0, 'pressureGamma must be in [0.3, 3.0]'), assert(palmRejectionMs >= 0.0 && palmRejectionMs <= 500.0, 'palmRejectionMs must be in [0, 500]'), assert(pressureSensitivity >= 0.0 && pressureSensitivity <= 1.0, 'pressureSensitivity must be in [0, 1]'); /// Which action fires when the side barrel button is held. final PenButtonAction sideButton; /// Which action fires when the eraser end of the pen is used. final PenButtonAction eraserEnd; /// Gamma exponent for the pressure curve: effective = pressure ^ gamma. /// Range: [0.3, 3.0], default 1.0 (linear). final double pressureGamma; /// Duration in milliseconds after a pen-down event during which touch /// contact is treated as palm and rejected. /// Range: [0, 500], default 150. final double palmRejectionMs; /// Whether finger touch strokes are drawn when no pen is present. final bool fingerDrawing; /// Pen stroke width as a fraction of the canvas width. final double penWidth; /// Highlighter stroke width as a fraction of the canvas width. final double highlighterWidth; /// How strongly stylus pressure modulates stroke width — maps directly to /// perfect_freehand's `thinning`. Range [0,1]; `0` = constant width, /// higher = pressure sweeps width more (Saber's `StrokeOptions.thinning` /// model). Default [kDefaultPenThinning] so the out-of-box feel and the /// export golden are unchanged. final double pressureSensitivity; PenConfig copyWith({ PenButtonAction? sideButton, PenButtonAction? eraserEnd, double? pressureGamma, double? palmRejectionMs, bool? fingerDrawing, double? penWidth, double? highlighterWidth, double? pressureSensitivity, }) { return PenConfig( sideButton: sideButton ?? this.sideButton, eraserEnd: eraserEnd ?? this.eraserEnd, pressureGamma: pressureGamma ?? this.pressureGamma, palmRejectionMs: palmRejectionMs ?? this.palmRejectionMs, fingerDrawing: fingerDrawing ?? this.fingerDrawing, penWidth: penWidth ?? this.penWidth, highlighterWidth: highlighterWidth ?? this.highlighterWidth, pressureSensitivity: pressureSensitivity ?? this.pressureSensitivity, ); } Map toJson() => { 'sideButton': sideButton.name, 'eraserEnd': eraserEnd.name, 'pressureGamma': pressureGamma, 'palmRejectionMs': palmRejectionMs, 'fingerDrawing': fingerDrawing, 'penWidth': penWidth, 'highlighterWidth': highlighterWidth, 'pressureSensitivity': pressureSensitivity, }; factory PenConfig.fromJson(Map json) { return PenConfig( sideButton: PenButtonAction.values.asNameMap()[json['sideButton'] as String? ?? ''] ?? PenButtonAction.eraser, eraserEnd: PenButtonAction.values.asNameMap()[json['eraserEnd'] as String? ?? ''] ?? PenButtonAction.eraser, pressureGamma: (json['pressureGamma'] as num?)?.toDouble() ?? 1.0, palmRejectionMs: (json['palmRejectionMs'] as num?)?.toDouble() ?? 150.0, fingerDrawing: json['fingerDrawing'] as bool? ?? false, penWidth: (json['penWidth'] as num?)?.toDouble() ?? 0.004, highlighterWidth: (json['highlighterWidth'] as num?)?.toDouble() ?? 0.02, pressureSensitivity: (json['pressureSensitivity'] as num?)?.toDouble() ?? kDefaultPenThinning, ); } @override bool operator ==(Object other) => identical(this, other) || other is PenConfig && runtimeType == other.runtimeType && sideButton == other.sideButton && eraserEnd == other.eraserEnd && pressureGamma == other.pressureGamma && palmRejectionMs == other.palmRejectionMs && fingerDrawing == other.fingerDrawing && penWidth == other.penWidth && highlighterWidth == other.highlighterWidth && pressureSensitivity == other.pressureSensitivity; @override int get hashCode => Object.hash( sideButton, eraserEnd, pressureGamma, palmRejectionMs, fingerDrawing, penWidth, highlighterWidth, pressureSensitivity, ); } /// Applies a gamma power curve to a raw pressure value. /// /// Returns `pressure.clamp(0, 1) ^ gamma` as a [double]. double applyPressureCurve(double pressure, double gamma) => pow(pressure.clamp(0.0, 1.0), gamma).toDouble(); /// Manages [PenConfig] persistence and live updates. /// /// Load with [PenConfigController.load], then listen to changes via /// [ChangeNotifier]. Setters immediately update the in-memory value, /// notify listeners, and persist to SharedPreferences. class PenConfigController extends ChangeNotifier { PenConfigController._(this._prefs, this._value); /// The SharedPreferences key under which [PenConfig] JSON is stored. static const prefsKey = 'pen_config_v1'; final SharedPreferences _prefs; PenConfig _value; /// The current pen configuration. PenConfig get value => _value; /// Loads persisted config from SharedPreferences. Falls back to defaults /// if no config has been saved yet or if the stored JSON is invalid. static Future load() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(prefsKey); PenConfig config; if (raw == null) { config = const PenConfig(); } else { try { config = PenConfig.fromJson( jsonDecode(raw) as Map, ); } catch (_) { config = const PenConfig(); } } return PenConfigController._(prefs, config); } Future _persist() async { await _prefs.setString(prefsKey, jsonEncode(_value.toJson())); } Future setSideButton(PenButtonAction action) async { _value = _value.copyWith(sideButton: action); notifyListeners(); await _persist(); } Future setEraserEnd(PenButtonAction action) async { _value = _value.copyWith(eraserEnd: action); notifyListeners(); await _persist(); } /// Sets [PenConfig.pressureGamma]. Clamped to [0.3, 3.0]. Future setPressureGamma(double gamma) async { _value = _value.copyWith(pressureGamma: gamma.clamp(0.3, 3.0)); notifyListeners(); await _persist(); } /// Sets [PenConfig.palmRejectionMs]. Clamped to [0, 500]. Future setPalmRejectionMs(double ms) async { _value = _value.copyWith(palmRejectionMs: ms.clamp(0.0, 500.0)); notifyListeners(); await _persist(); } Future setFingerDrawing(bool enabled) async { _value = _value.copyWith(fingerDrawing: enabled); notifyListeners(); await _persist(); } Future setPenWidth(double width) async { _value = _value.copyWith(penWidth: width); notifyListeners(); await _persist(); } Future setHighlighterWidth(double width) async { _value = _value.copyWith(highlighterWidth: width); notifyListeners(); await _persist(); } /// Sets [PenConfig.pressureSensitivity]. Clamped to [0, 1]. Future setPressureSensitivity(double sensitivity) async { _value = _value.copyWith(pressureSensitivity: sensitivity.clamp(0.0, 1.0)); notifyListeners(); await _persist(); } }