stylet 0.1.2
stylet: ^0.1.2 copied to clipboard
Cross-platform Flutter stylus input with pressure, tilt, buttons, barrel rotation, hover, double-tap, and squeeze support.
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:stylet/stylet.dart';
/// Starts the interactive Stylet example.
void main() => runApp(const StyletExampleApp());
/// Demonstrates every portable and native stylus value exposed by Stylet.
class StyletExampleApp extends StatelessWidget {
/// Creates the example application.
const StyletExampleApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Stylet example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xff6750a4),
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const _StylusLaboratory(),
);
}
/// Interactive canvas that visualizes live stylus samples.
class _StylusLaboratory extends StatefulWidget {
/// Creates the stylus laboratory page.
const _StylusLaboratory();
@override
State<_StylusLaboratory> createState() => _StylusLaboratoryState();
}
/// Holds the latest sample, action, capabilities, and short pointer trail.
class _StylusLaboratoryState extends State<_StylusLaboratory> {
/// Maximum number of points retained by the trail painter.
static const int _maximumTrailLength = 160;
/// Shared plugin controller used by the demo.
final Stylet _stylet = Stylet.instance;
/// Recent local pointer positions rendered on the canvas.
final List<Offset> _trail = [];
/// Latest normalized pointer sample.
StylusMotionEvent? _motion;
/// Latest double-tap or squeeze interaction.
StylusActionEvent? _action;
/// Platform features resolved asynchronously after startup.
StylusCapabilities? _capabilities;
/// Subscription that refreshes native device telemetry.
StreamSubscription<StylusDeviceEvent>? _deviceSubscription;
/// Subscription that refreshes graphics-tablet pad telemetry.
StreamSubscription<TabletPadEvent>? _padSubscription;
/// Latest native device connection or metadata change.
StylusDeviceEvent? _deviceEvent;
/// Latest graphics-tablet pad control change.
TabletPadEvent? _padEvent;
/// Latest replaceable predicted trajectory.
StylusPredictionEvent? _prediction;
/// Latest correction for an initially estimated sample.
StylusCorrectionEvent? _correction;
@override
void initState() {
super.initState();
_deviceSubscription = _stylet.deviceEvents.listen(_handleDeviceEvent);
_padSubscription = _stylet.padEvents.listen(_handlePadEvent);
_loadCapabilities();
}
@override
void dispose() {
unawaited(_deviceSubscription?.cancel());
unawaited(_padSubscription?.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Stylet input laboratory')),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Draw, hover, rotate the barrel, or use a stylus body gesture.',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 12),
_CapabilityStrip(capabilities: _capabilities),
const SizedBox(height: 12),
Expanded(
child: StyletListener(
stylet: _stylet,
behavior: HitTestBehavior.opaque,
onEvent: _handleMotion,
onAction: _handleAction,
onPrediction: _handlePrediction,
onCorrection: _handleCorrection,
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: CustomPaint(
painter: _StylusTrailPainter(
points: List.unmodifiable(_trail),
motion: _motion,
),
child: const SizedBox.expand(),
),
),
),
),
),
const SizedBox(height: 12),
_TelemetryPanel(
motion: _motion,
action: _action,
deviceEvent: _deviceEvent,
padEvent: _padEvent,
prediction: _prediction,
correction: _correction,
deviceCount: _stylet.connectedDevices.length,
),
],
),
),
),
);
/// Resolves backend capabilities while tolerating a disappearing page.
Future<void> _loadCapabilities() async {
final StylusCapabilities capabilities = await _stylet.capabilities;
if (mounted) {
setState(() => _capabilities = capabilities);
}
}
/// Stores one motion sample and maintains a bounded visual trail.
void _handleMotion(StylusMotionEvent event) {
setState(() {
_motion = event;
if (event.phase == StylusPhase.down) {
_trail.clear();
}
if (event.phase == StylusPhase.down || event.phase == StylusPhase.move) {
_trail.add(event.localPosition);
if (_trail.length > _maximumTrailLength) {
_trail.removeRange(0, _trail.length - _maximumTrailLength);
}
}
});
}
/// Stores the latest stylus body interaction for inspection.
void _handleAction(StylusActionEvent event) =>
setState(() => _action = event);
/// Stores the latest native device change for inspection.
void _handleDeviceEvent(StylusDeviceEvent event) =>
setState(() => _deviceEvent = event);
/// Stores the latest graphics-tablet control event for inspection.
void _handlePadEvent(TabletPadEvent event) =>
setState(() => _padEvent = event);
/// Stores the latest replacement prediction for inspection.
void _handlePrediction(StylusPredictionEvent event) =>
setState(() => _prediction = event);
/// Stores the latest estimated-property correction for inspection.
void _handleCorrection(StylusCorrectionEvent event) =>
setState(() => _correction = event);
}
/// Displays the advanced features offered by the current backend.
class _CapabilityStrip extends StatelessWidget {
/// Capability description, or `null` while it is loading.
final StylusCapabilities? capabilities;
/// Creates a horizontally wrapping capability list.
const _CapabilityStrip({required this.capabilities});
@override
Widget build(BuildContext context) {
final StylusCapabilities? value = capabilities;
if (value == null) {
return const LinearProgressIndicator();
}
return Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final StylusFeature feature in value.features)
Chip(label: Text(feature.name)),
],
);
}
}
/// Displays numerical data from the most recent stylus events.
class _TelemetryPanel extends StatelessWidget {
/// Latest normalized motion sample.
final StylusMotionEvent? motion;
/// Latest stylus body interaction.
final StylusActionEvent? action;
/// Latest native device connection or metadata change.
final StylusDeviceEvent? deviceEvent;
/// Latest graphics-tablet pad control change.
final TabletPadEvent? padEvent;
/// Latest replaceable trajectory prediction.
final StylusPredictionEvent? prediction;
/// Latest definitive update for an estimated sample.
final StylusCorrectionEvent? correction;
/// Number of native devices currently tracked by the controller.
final int deviceCount;
/// Creates a telemetry panel from optional live data.
const _TelemetryPanel({
required this.motion,
required this.action,
required this.deviceEvent,
required this.padEvent,
required this.prediction,
required this.correction,
required this.deviceCount,
});
@override
Widget build(BuildContext context) {
final StylusMotionEvent? sample = motion;
final String? preferredAction = action?.preferredAction?.name;
final String actionLabel = action == null
? '—'
: '${action!.action.name} · ${action!.phase.name}'
'${preferredAction == null ? '' : ' · $preferredAction'}';
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(14),
child: Wrap(
spacing: 24,
runSpacing: 10,
children: [
_Metric(label: 'Phase', value: sample?.phase.name ?? '—'),
_Metric(label: 'Tool', value: sample?.tool.name ?? '—'),
_Metric(
label: 'Pressure',
value: _formatNumber(sample?.normalizedPressure),
),
_Metric(label: 'Tilt', value: _formatDegrees(sample?.tilt)),
_Metric(
label: 'Azimuth',
value: _formatDegrees(sample?.orientation),
),
_Metric(
label: 'Barrel',
value: _formatDegrees(sample?.barrelRotation),
),
_Metric(label: 'Wheel', value: _formatDegrees(sample?.wheelDelta)),
_Metric(
label: 'Button 1',
value: sample?.isSideButtonPressed(number: 1) ?? false
? 'down'
: 'up',
),
_Metric(label: 'Action', value: actionLabel),
_Metric(label: 'Devices', value: deviceCount.toString()),
_Metric(
label: 'Device event',
value: _formatDeviceEvent(deviceEvent),
),
_Metric(label: 'Pad event', value: _formatPadEvent(padEvent)),
_Metric(
label: 'Prediction',
value: '${prediction?.samples.length ?? 0} samples',
),
_Metric(
label: 'Correction',
value: correction == null
? '—'
: correction!.correctedProperties
.map((property) => property.name)
.join(', '),
),
],
),
),
);
}
}
/// One compact label-value pair in the telemetry panel.
class _Metric extends StatelessWidget {
/// Short metric label.
final String label;
/// Human-readable current value.
final String value;
/// Creates a telemetry metric.
const _Metric({required this.label, required this.value});
@override
Widget build(BuildContext context) => SizedBox(
width: 132,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelMedium
?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
);
}
/// Paints a pressure-sensitive trail and a rotated stylus nib preview.
class _StylusTrailPainter extends CustomPainter {
/// Recent local pointer positions.
final List<Offset> points;
/// Latest sample controlling width and nib angle.
final StylusMotionEvent? motion;
/// Creates a painter from immutable input snapshots.
const _StylusTrailPainter({required this.points, required this.motion});
@override
void paint(Canvas canvas, Size size) {
final StylusMotionEvent? sample = motion;
final double pressure = sample?.normalizedPressure ?? 0.5;
final Paint trailPaint = Paint()
..color = const Color(0xffd0bcff)
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..strokeWidth = 2 + pressure * 14
..style = PaintingStyle.stroke;
if (points.length > 1) {
final Path path = Path()..moveTo(points.first.dx, points.first.dy);
for (final Offset point in points.skip(1)) {
path.lineTo(point.dx, point.dy);
}
canvas.drawPath(path, trailPaint);
}
if (sample == null) {
return;
}
final double rotation = sample.barrelRotation ?? sample.orientation ?? 0;
canvas
..save()
..translate(sample.localPosition.dx, sample.localPosition.dy)
..rotate(rotation)
..drawOval(
Rect.fromCenter(center: Offset.zero, width: 28, height: 8),
Paint()..color = const Color(0xffffd8e4),
)
..restore();
}
@override
bool shouldRepaint(covariant _StylusTrailPainter oldDelegate) =>
oldDelegate.motion != motion || oldDelegate.points != points;
}
/// Formats an optional normalized value for telemetry.
String _formatNumber(double? value) =>
value == null ? '—' : value.toStringAsFixed(3);
/// Formats an optional radian angle as degrees for telemetry.
String _formatDegrees(double? radians) =>
radians == null ? '—' : '${(radians * 180 / math.pi).toStringAsFixed(1)}°';
/// Formats an optional native device change for compact telemetry.
String _formatDeviceEvent(StylusDeviceEvent? event) {
if (event == null) {
return '—';
}
final String label = event.device.name ?? event.device.identifier;
return '${event.phase.name} · $label';
}
/// Formats an optional graphics-tablet pad event for compact telemetry.
String _formatPadEvent(TabletPadEvent? event) {
if (event == null) {
return '—';
}
final double? rawValue = event.value;
final String value = rawValue == null
? ''
: ' · ${rawValue.toStringAsFixed(3)}';
return '${event.control.name}[${event.controlIndex}] · ${event.phase.name}$value';
}