All checks were successful
CI / Windows build (push) Successful in 9m55s
Reinstall PenCaptureBinding so stylus ink hits again; keep finger Listener translucent under pinch; page-anchor sticky with drag/resize; OneNote pen slots (brush+width+color); blank-note multi-page; default side button to hold-select-text. Co-authored-by: Cursor <cursoragent@cursor.com>
214 lines
5.7 KiB
Dart
214 lines
5.7 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../engine/brush.dart';
|
|
|
|
/// OneNote-style independent pen slot: brush + color + thickness together.
|
|
///
|
|
/// Selecting a slot restores all three; color dots / thickness controls edit
|
|
/// only the active slot.
|
|
class PenSlot {
|
|
const PenSlot({
|
|
required this.id,
|
|
required this.brush,
|
|
required this.color,
|
|
required this.width,
|
|
});
|
|
|
|
final String id;
|
|
|
|
/// Brush kind for this slot (fountain / ballpoint / pencil — not highlighter).
|
|
final BrushKind brush;
|
|
|
|
final Color color;
|
|
|
|
/// Stroke width as a fraction of page width.
|
|
final double width;
|
|
|
|
PenSlot copyWith({
|
|
String? id,
|
|
BrushKind? brush,
|
|
Color? color,
|
|
double? width,
|
|
}) {
|
|
return PenSlot(
|
|
id: id ?? this.id,
|
|
brush: brush ?? this.brush,
|
|
color: color ?? this.color,
|
|
width: width ?? this.width,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'id': id,
|
|
'brush': brush.name,
|
|
'color': color.toARGB32(),
|
|
'width': width,
|
|
};
|
|
|
|
factory PenSlot.fromJson(Map<String, dynamic> json) {
|
|
final brushName = json['brush'] as String? ?? '';
|
|
return PenSlot(
|
|
id: json['id'] as String? ?? 'slot_0',
|
|
brush: BrushKind.values.asNameMap()[brushName] ?? BrushKind.fountainPen,
|
|
color: Color(json['color'] as int? ?? 0xFF000000),
|
|
width: (json['width'] as num?)?.toDouble() ?? 0.006,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
identical(this, other) ||
|
|
other is PenSlot &&
|
|
runtimeType == other.runtimeType &&
|
|
id == other.id &&
|
|
brush == other.brush &&
|
|
color.toARGB32() == other.color.toARGB32() &&
|
|
width == other.width;
|
|
|
|
@override
|
|
int get hashCode => Object.hash(id, brush, color.toARGB32(), width);
|
|
}
|
|
|
|
/// Default pen slots seeded OneNote-style (fountain / ballpoint / pencil).
|
|
List<PenSlot> kDefaultPenSlots() => const [
|
|
PenSlot(
|
|
id: 'slot_0',
|
|
brush: BrushKind.fountainPen,
|
|
color: Colors.black,
|
|
width: 0.006,
|
|
),
|
|
PenSlot(
|
|
id: 'slot_1',
|
|
brush: BrushKind.ballpoint,
|
|
color: Colors.blue,
|
|
width: 0.0022,
|
|
),
|
|
PenSlot(
|
|
id: 'slot_2',
|
|
brush: BrushKind.pencil,
|
|
color: Colors.green,
|
|
width: 0.003,
|
|
),
|
|
];
|
|
|
|
/// S / M / L thickness presets (page-width fractions) for the toolbar picker.
|
|
const double kThicknessSmall = 0.0022;
|
|
const double kThicknessMedium = 0.006;
|
|
const double kThicknessLarge = 0.012;
|
|
|
|
/// Allowed range for slot stroke width (page-width fraction).
|
|
const double kPenSlotWidthMin = 0.001;
|
|
const double kPenSlotWidthMax = 0.05;
|
|
|
|
/// Manages independent [PenSlot]s with SharedPreferences persistence.
|
|
///
|
|
/// Load with [PenSlotsController.load], then listen via [ChangeNotifier].
|
|
class PenSlotsController extends ChangeNotifier {
|
|
PenSlotsController._(this._prefs, this._slots, this._activeId);
|
|
|
|
/// SharedPreferences key for the slots JSON blob.
|
|
static const prefsKey = 'pen_slots_v1';
|
|
|
|
final SharedPreferences _prefs;
|
|
List<PenSlot> _slots;
|
|
String _activeId;
|
|
|
|
List<PenSlot> get slots => List.unmodifiable(_slots);
|
|
|
|
String get activeId => _activeId;
|
|
|
|
PenSlot get active {
|
|
for (final s in _slots) {
|
|
if (s.id == _activeId) return s;
|
|
}
|
|
return _slots.first;
|
|
}
|
|
|
|
/// Loads persisted slots, or seeds [kDefaultPenSlots] on first run / corrupt
|
|
/// JSON.
|
|
static Future<PenSlotsController> load() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final raw = prefs.getString(prefsKey);
|
|
var slots = kDefaultPenSlots();
|
|
var activeId = slots.first.id;
|
|
if (raw != null) {
|
|
try {
|
|
final map = jsonDecode(raw) as Map<String, dynamic>;
|
|
final list = map['slots'] as List<dynamic>?;
|
|
if (list != null && list.isNotEmpty) {
|
|
slots = [
|
|
for (final e in list)
|
|
PenSlot.fromJson(e as Map<String, dynamic>),
|
|
];
|
|
}
|
|
final storedActive = map['activeId'] as String?;
|
|
if (storedActive != null &&
|
|
slots.any((s) => s.id == storedActive)) {
|
|
activeId = storedActive;
|
|
} else {
|
|
activeId = slots.first.id;
|
|
}
|
|
} catch (_) {
|
|
slots = kDefaultPenSlots();
|
|
activeId = slots.first.id;
|
|
}
|
|
}
|
|
return PenSlotsController._(prefs, slots, activeId);
|
|
}
|
|
|
|
Future<void> _persist() async {
|
|
await _prefs.setString(
|
|
prefsKey,
|
|
jsonEncode({
|
|
'activeId': _activeId,
|
|
'slots': [for (final s in _slots) s.toJson()],
|
|
}),
|
|
);
|
|
}
|
|
|
|
int _indexOfActive() {
|
|
final i = _slots.indexWhere((s) => s.id == _activeId);
|
|
return i >= 0 ? i : 0;
|
|
}
|
|
|
|
void _replaceActive(PenSlot next) {
|
|
final i = _indexOfActive();
|
|
_slots = [..._slots]..[i] = next;
|
|
}
|
|
|
|
/// Selects [id] as the active slot (restores brush + color + width).
|
|
Future<void> select(String id) async {
|
|
if (!_slots.any((s) => s.id == id)) return;
|
|
if (_activeId == id) return;
|
|
_activeId = id;
|
|
notifyListeners();
|
|
await _persist();
|
|
}
|
|
|
|
/// Sets the active slot's color.
|
|
Future<void> setActiveColor(Color c) async {
|
|
_replaceActive(active.copyWith(color: c));
|
|
notifyListeners();
|
|
await _persist();
|
|
}
|
|
|
|
/// Sets the active slot's stroke width (clamped).
|
|
Future<void> setActiveWidth(double w) async {
|
|
final clamped = w.clamp(kPenSlotWidthMin, kPenSlotWidthMax);
|
|
_replaceActive(active.copyWith(width: clamped));
|
|
notifyListeners();
|
|
await _persist();
|
|
}
|
|
|
|
/// Sets the active slot's brush kind.
|
|
Future<void> setActiveBrush(BrushKind b) async {
|
|
if (b == BrushKind.highlighter) return;
|
|
_replaceActive(active.copyWith(brush: b));
|
|
notifyListeners();
|
|
await _persist();
|
|
}
|
|
}
|