import 'package:flutter/material.dart'; import '../input/pen_config.dart'; import '../input/pressure_curve.dart'; String _shapeNameForGamma(double gamma) { if ((gamma - 0.6).abs() < 0.05) return 'soft'; if ((gamma - 1.0).abs() < 0.05) return 'linear'; if ((gamma - 2.0).abs() < 0.05) return 'quadratic'; if ((gamma - 3.0).abs() < 0.05) return 'cubic'; if ((gamma - 0.5).abs() < 0.05) return 'sqrt'; return 'soft'; } PressureCurve _curveForName(String name) => switch (name) { 'linear' => PressureCurve.shaped(PressureCurveShape.linear), 'quadratic' => PressureCurve.shaped(PressureCurveShape.quadratic), 'cubic' => PressureCurve.shaped(PressureCurveShape.cubic), 'sqrt' => PressureCurve.shaped(PressureCurveShape.sqrt), 'logarithmic' => PressureCurve.shaped(PressureCurveShape.soft), // gamma proxy _ => PressureCurve.shaped(PressureCurveShape.soft), }; /// Shows a Material You modal bottom sheet for configuring pen input. /// /// Changes are applied and persisted immediately via [controller] setters, /// so the sheet reflects the live configuration at all times. Future showPenSettingsSheet( BuildContext context, PenConfigController controller, ) { return showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), builder: (context) => _PenSettingsSheet(controller: controller), ); } class _PenSettingsSheet extends StatelessWidget { const _PenSettingsSheet({required this.controller}); final PenConfigController controller; @override Widget build(BuildContext context) { return ListenableBuilder( listenable: controller, builder: (context, _) { final config = controller.value; final colorScheme = Theme.of(context).colorScheme; return DraggableScrollableSheet( expand: false, initialChildSize: 0.7, minChildSize: 0.4, maxChildSize: 0.95, builder: (context, scrollController) { return ListView( controller: scrollController, padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), children: [ // Drag handle Center( child: Container( width: 32, height: 4, margin: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( color: colorScheme.onSurfaceVariant.withAlpha(80), borderRadius: BorderRadius.circular(2), ), ), ), Text( 'Pen Settings', style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.center, ), const SizedBox(height: 16), // ── Button Actions ──────────────────────────────────────── _SectionHeader( title: 'Button Actions', icon: Icons.touch_app, colorScheme: colorScheme, ), const SizedBox(height: 8), _LabeledRow( label: 'Side Button', child: _ActionDropdown( value: config.sideButton, onChanged: controller.setSideButton, ), ), const SizedBox(height: 8), _LabeledRow( label: 'Eraser End', child: _ActionDropdown( value: config.eraserEnd, onChanged: controller.setEraserEnd, ), ), const SizedBox(height: 16), // ── Pressure ────────────────────────────────────────────── _SectionHeader( title: 'Pressure', icon: Icons.compress, colorScheme: colorScheme, ), _SliderTile( label: 'Pressure Sensitivity', value: config.pressureSensitivity, min: 0.0, max: 1.0, divisions: 20, formatValue: (v) => v.toStringAsFixed(2), onChanged: controller.setPressureSensitivity, ), _SliderTile( label: 'Pressure Gamma', value: config.pressureGamma, min: 0.3, max: 3.0, divisions: 27, formatValue: (v) => v.toStringAsFixed(2), onChanged: controller.setPressureGamma, ), _LabeledRow( label: 'Curve Preset (rnote)', child: DropdownMenu( initialSelection: _shapeNameForGamma(config.pressureGamma), onSelected: (name) { if (name == null) return; final shaped = _curveForName(name); controller.setPressureGamma(shaped.gamma); }, dropdownMenuEntries: const [ DropdownMenuEntry(value: 'soft', label: 'Soft (γ≈0.6)'), DropdownMenuEntry(value: 'linear', label: 'Linear'), DropdownMenuEntry( value: 'quadratic', label: 'Quadratic / Pow2'), DropdownMenuEntry(value: 'cubic', label: 'Cubic'), DropdownMenuEntry(value: 'sqrt', label: 'Sqrt (pencil)'), ], ), ), const Padding( padding: EdgeInsets.only(left: 8, bottom: 8), child: Text( '笔刷自带曲线优先(钢笔=二次/Pow2,铅笔=平方根)。全局 gamma 作后备。', style: TextStyle(fontSize: 12), ), ), // ── Input ───────────────────────────────────────────────── _SectionHeader( title: 'Input', icon: Icons.pan_tool_alt, colorScheme: colorScheme, ), _SliderTile( label: 'Palm Rejection', value: config.palmRejectionMs, min: 0, max: 500, divisions: 50, formatValue: (v) => '${v.round()} ms', onChanged: controller.setPalmRejectionMs, ), SwitchListTile( title: const Text('Finger Drawing'), subtitle: const Text( 'Allow touch strokes when no pen is detected', ), value: config.fingerDrawing, onChanged: controller.setFingerDrawing, contentPadding: EdgeInsets.zero, ), // ── Stroke Widths ───────────────────────────────────────── _SectionHeader( title: 'Stroke Widths', icon: Icons.line_weight, colorScheme: colorScheme, ), _SliderTile( label: 'Pen Width', value: config.penWidth, min: 0.001, max: 0.02, divisions: 19, formatValue: (v) => v.toStringAsFixed(4), onChanged: controller.setPenWidth, ), _SliderTile( label: 'Highlighter Width', value: config.highlighterWidth, min: 0.005, max: 0.06, divisions: 22, formatValue: (v) => v.toStringAsFixed(4), onChanged: controller.setHighlighterWidth, ), // ── Eraser ──────────────────────────────────────────────── _SectionHeader( title: 'Eraser', icon: Icons.cleaning_services_outlined, colorScheme: colorScheme, ), _SliderTile( label: 'Eraser Size', value: config.eraserRadius, min: 0.005, max: 0.1, divisions: 19, formatValue: (v) => v.toStringAsFixed(3), onChanged: controller.setEraserRadius, ), SwitchListTile( title: const Text('Stroke Eraser'), subtitle: const Text( 'Erase a whole stroke on contact (off: erase by segment)', ), value: config.eraserWholeStroke, onChanged: controller.setEraserWholeStroke, contentPadding: EdgeInsets.zero, ), ], ); }, ); }, ); } } // ── Shared section header ──────────────────────────────────────────────────── class _SectionHeader extends StatelessWidget { const _SectionHeader({ required this.title, required this.icon, required this.colorScheme, }); final String title; final IconData icon; final ColorScheme colorScheme; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(top: 8, bottom: 4), child: Row( children: [ Icon(icon, size: 18, color: colorScheme.primary), const SizedBox(width: 8), Text( title, style: Theme.of(context).textTheme.titleSmall?.copyWith( color: colorScheme.primary, fontWeight: FontWeight.bold, ), ), const SizedBox(width: 8), Expanded(child: Divider(color: colorScheme.outlineVariant)), ], ), ); } } // ── Label + widget row ─────────────────────────────────────────────────────── class _LabeledRow extends StatelessWidget { const _LabeledRow({required this.label, required this.child}); final String label; final Widget child; @override Widget build(BuildContext context) { return Row( children: [ SizedBox( width: 120, child: Text( label, style: const TextStyle(fontWeight: FontWeight.w500), ), ), Expanded(child: child), ], ); } } // ── Action dropdown ────────────────────────────────────────────────────────── class _ActionDropdown extends StatelessWidget { const _ActionDropdown({required this.value, required this.onChanged}); final PenButtonAction value; final ValueChanged onChanged; static String _label(PenButtonAction action) => switch (action) { PenButtonAction.none => '无', PenButtonAction.eraser => '橡皮', PenButtonAction.undo => '撤销', PenButtonAction.toggleTool => '切换工具', PenButtonAction.pan => '平移', PenButtonAction.select => '选择(笔迹)', PenButtonAction.selectText => '选择文本', }; @override Widget build(BuildContext context) { return DropdownMenu( initialSelection: value, expandedInsets: EdgeInsets.zero, onSelected: (action) { if (action != null) onChanged(action); }, dropdownMenuEntries: PenButtonAction.values .map( (a) => DropdownMenuEntry(value: a, label: _label(a)), ) .toList(), ); } } // ── Slider with live value label ───────────────────────────────────────────── class _SliderTile extends StatelessWidget { const _SliderTile({ required this.label, required this.value, required this.min, required this.max, required this.divisions, required this.formatValue, required this.onChanged, }); final String label; final double value; final double min; final double max; final int divisions; final String Function(double) formatValue; final ValueChanged onChanged; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.w500)), Text( formatValue(value), style: TextStyle( fontFamily: 'monospace', color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 13, ), ), ], ), Slider( value: value.clamp(min, max), min: min, max: max, divisions: divisions, label: formatValue(value), onChanged: onChanged, ), ], ); } }