flutter_apple_pencil 1.0.0 copy "flutter_apple_pencil: ^1.0.0" to clipboard
flutter_apple_pencil: ^1.0.0 copied to clipboard

PlatformiOS

A Flutter plugin for detecting Apple Pencil interactions including double-tap and squeeze gestures.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter_apple_pencil/flutter_apple_pencil.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Apple Pencil Example',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  final FlutterApplePencil _applePencil = FlutterApplePencil();
  StreamSubscription<PencilTap>? _tapSubscription;
  StreamSubscription<PencilSqueeze>? _squeezeSubscription;

  bool _isSupported = false;
  bool _isInitialized = false;

  final List<PencilTap> _taps = [];
  final List<PencilSqueeze> _squeezes = [];

  PencilTap? _lastTap;
  PencilSqueeze? _lastSqueeze;

  // User preferences
  PencilPreferredAction? _preferredTapAction;
  PencilPreferredAction? _preferredSqueezeAction;
  bool _prefersPencilOnlyDrawing = false;
  bool _prefersHoverToolPreview = false;

  // Animation controller for visual feedback
  late AnimationController _animationController;
  late Animation<double> _scaleAnimation;

  @override
  void initState() {
    super.initState();
    _setupAnimation();
    _initializePlugin();
  }

  void _setupAnimation() {
    _animationController = AnimationController(
      duration: const Duration(milliseconds: 300),
      vsync: this,
    );

    _scaleAnimation = Tween<double>(begin: 1.0, end: 1.2).animate(
      CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
    );
  }

  Future<void> _initializePlugin() async {
    try {
      // Check if Apple Pencil is supported
      final isSupported = await _applePencil.isSupported();

      setState(() {
        _isSupported = isSupported;
      });

      if (isSupported) {
        // Initialize the plugin
        await _applePencil.initialize();

        // Load user preferences
        _preferredTapAction = await _applePencil.preferredTapAction;
        _preferredSqueezeAction = await _applePencil.preferredSqueezeAction;
        _prefersPencilOnlyDrawing = await _applePencil.prefersPencilOnlyDrawing;
        _prefersHoverToolPreview = await _applePencil.prefersHoverToolPreview;

        setState(() {
          _isInitialized = true;
        });

        // Listen to tap events
        _tapSubscription = _applePencil.onPencilDoubleTap.listen((tap) {
          _handleTap(tap);
        });

        // Listen to squeeze events
        _squeezeSubscription = _applePencil.onPencilSqueeze.listen((squeeze) {
          _handleSqueeze(squeeze);
        });
      }
    } catch (e) {
      _showError('Failed to initialize: $e');
    }
  }

  void _handleTap(PencilTap tap) {
    debugPrint('Pencil Tap: $tap');
    setState(() {
      _lastTap = tap;
      _taps.insert(0, tap);
    });

    _animationController.forward().then((_) => _animationController.reverse());
  }

  void _handleSqueeze(PencilSqueeze squeeze) {
    debugPrint('Pencil Squeeze: $squeeze');
    setState(() {
      _lastSqueeze = squeeze;
      _squeezes.insert(0, squeeze);
    });

    _animationController.forward().then((_) => _animationController.reverse());
  }

  void _showError(String message) {
    if (mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text(message),
          backgroundColor: Colors.red,
        ),
      );
    }
  }

  @override
  void dispose() {
    _tapSubscription?.cancel();
    _squeezeSubscription?.cancel();
    _animationController.dispose();
    _applePencil.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Apple Pencil Example'),
        actions: [
          if (_isSupported && _isInitialized)
            IconButton(
              icon: const Icon(Icons.settings),
              onPressed: _showPreferences,
            ),
        ],
      ),
      body: _buildBody(),
    );
  }

  Widget _buildBody() {
    if (!_isSupported) {
      return _buildUnsupportedView();
    }

    if (!_isInitialized) {
      return _buildLoadingView();
    }

    return _buildMainView();
  }

  Widget _buildUnsupportedView() {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(
            Icons.warning_amber_rounded,
            size: 64,
            color: Colors.orange[700],
          ),
          const SizedBox(height: 16),
          Text(
            'Apple Pencil Not Supported',
            style: Theme.of(context).textTheme.titleLarge,
          ),
          const SizedBox(height: 8),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 32.0),
            child: Text(
              'This device does not support Apple Pencil interactions.',
              textAlign: TextAlign.center,
              style: Theme.of(context).textTheme.bodyMedium,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildLoadingView() {
    return const Center(
      child: CircularProgressIndicator(),
    );
  }

  Widget _buildMainView() {
    return SingleChildScrollView(
      padding: const EdgeInsets.all(16.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          _buildStatusCard(),
          const SizedBox(height: 16),
          if (_lastTap != null || _lastSqueeze != null) ...[
            _buildLastInteractionsCard(),
            const SizedBox(height: 16),
          ],
          _buildHistoryCard(),
        ],
      ),
    );
  }

  Widget _buildStatusCard() {
    return Card(
      elevation: 2,
      child: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Status',
              style: Theme.of(context).textTheme.titleLarge?.copyWith(
                    fontWeight: FontWeight.bold,
                  ),
            ),
            const SizedBox(height: 16),
            _buildStatusRow(
              Icons.check_circle,
              'Device Support',
              'Enabled',
              Colors.green,
            ),
            const SizedBox(height: 8),
            _buildStatusRow(
              Icons.touch_app,
              'Total Taps',
              '${_taps.length}',
              Colors.blue,
            ),
            const SizedBox(height: 8),
            _buildStatusRow(
              Icons.gesture,
              'Total Squeezes',
              '${_squeezes.length}',
              Colors.purple,
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildStatusRow(
      IconData icon, String label, String value, Color color) {
    return Row(
      children: [
        Icon(icon, color: color, size: 20),
        const SizedBox(width: 12),
        Expanded(
          child: Text(
            label,
            style: Theme.of(context).textTheme.bodyMedium,
          ),
        ),
        Text(
          value,
          style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                fontWeight: FontWeight.bold,
                color: color,
              ),
        ),
      ],
    );
  }

  Widget _buildLastInteractionsCard() {
    return Card(
      elevation: 4,
      child: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Last Interactions',
              style: Theme.of(context).textTheme.titleLarge?.copyWith(
                    fontWeight: FontWeight.bold,
                  ),
            ),
            const SizedBox(height: 16),
            if (_lastTap != null) ...[
              _buildTapDetails(_lastTap!),
              if (_lastSqueeze != null) const SizedBox(height: 16),
            ],
            if (_lastSqueeze != null) _buildSqueezeDetails(_lastSqueeze!),
          ],
        ),
      ),
    );
  }

  Widget _buildTapDetails(PencilTap tap) {
    return ScaleTransition(
      scale: _scaleAnimation,
      child: Container(
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          color: Colors.blue[50],
          borderRadius: BorderRadius.circular(12),
          border: Border.all(color: Colors.blue[200]!, width: 2),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Icon(Icons.touch_app, color: Colors.blue[700], size: 32),
                const SizedBox(width: 12),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'Double-Tap',
                        style:
                            Theme.of(context).textTheme.titleMedium?.copyWith(
                                  fontWeight: FontWeight.bold,
                                  color: Colors.blue[900],
                                ),
                      ),
                      Text(
                        _formatTimestamp(tap.timestamp),
                        style: Theme.of(context).textTheme.bodySmall?.copyWith(
                              color: Colors.grey[700],
                            ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
            if (tap.hoverPose != null) ...[
              const SizedBox(height: 12),
              const Divider(),
              const SizedBox(height: 8),
              _buildHoverPoseInfo(tap.hoverPose!),
            ],
          ],
        ),
      ),
    );
  }

  Widget _buildSqueezeDetails(PencilSqueeze squeeze) {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.purple[50],
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: Colors.purple[200]!, width: 2),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Icon(Icons.gesture, color: Colors.purple[700], size: 32),
              const SizedBox(width: 12),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Row(
                      children: [
                        Text(
                          'Squeeze',
                          style:
                              Theme.of(context).textTheme.titleMedium?.copyWith(
                                    fontWeight: FontWeight.bold,
                                    color: Colors.purple[900],
                                  ),
                        ),
                        const SizedBox(width: 8),
                        Container(
                          padding: const EdgeInsets.symmetric(
                              horizontal: 8, vertical: 4),
                          decoration: BoxDecoration(
                            color: _getPhaseColor(squeeze.phase),
                            borderRadius: BorderRadius.circular(12),
                          ),
                          child: Text(
                            squeeze.phase.name.toUpperCase(),
                            style: const TextStyle(
                              fontSize: 10,
                              color: Colors.white,
                              fontWeight: FontWeight.bold,
                            ),
                          ),
                        ),
                      ],
                    ),
                    Text(
                      _formatTimestamp(squeeze.timestamp),
                      style: Theme.of(context).textTheme.bodySmall?.copyWith(
                            color: Colors.grey[700],
                          ),
                    ),
                  ],
                ),
              ),
            ],
          ),
          if (squeeze.hoverPose != null) ...[
            const SizedBox(height: 12),
            const Divider(),
            const SizedBox(height: 8),
            _buildHoverPoseInfo(squeeze.hoverPose!),
          ],
        ],
      ),
    );
  }

  Widget _buildHoverPoseInfo(PencilHoverPose pose) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          'Hover Information',
          style: Theme.of(context).textTheme.titleSmall?.copyWith(
                fontWeight: FontWeight.bold,
              ),
        ),
        const SizedBox(height: 8),
        _buildInfoRow('Location',
            '(${pose.location.dx.toStringAsFixed(1)}, ${pose.location.dy.toStringAsFixed(1)})'),
        _buildInfoRow('Z-Offset', pose.zOffset.toStringAsFixed(3)),
        _buildInfoRow('Altitude',
            '${_radiansToDegrees(pose.altitudeAngle).toStringAsFixed(1)}°'),
        _buildInfoRow('Azimuth',
            '${_radiansToDegrees(pose.azimuthAngle).toStringAsFixed(1)}°'),
        if (pose.rollAngle != 0)
          _buildInfoRow('Roll',
              '${_radiansToDegrees(pose.rollAngle).toStringAsFixed(1)}°'),
      ],
    );
  }

  Widget _buildInfoRow(String label, String value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 2.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text(
            label,
            style: Theme.of(context).textTheme.bodySmall?.copyWith(
                  color: Colors.grey[600],
                ),
          ),
          Text(
            value,
            style: Theme.of(context).textTheme.bodySmall?.copyWith(
                  fontWeight: FontWeight.bold,
                ),
          ),
        ],
      ),
    );
  }

  Widget _buildHistoryCard() {
    final allEvents = <dynamic>[..._taps, ..._squeezes]
      ..sort((a, b) => b.timestamp.compareTo(a.timestamp));

    if (allEvents.isEmpty) {
      return Card(
        elevation: 2,
        child: Padding(
          padding: const EdgeInsets.all(40.0),
          child: Column(
            children: [
              Icon(
                Icons.history,
                size: 48,
                color: Colors.grey[400],
              ),
              const SizedBox(height: 16),
              Text(
                'No interactions yet',
                style: Theme.of(context).textTheme.titleMedium?.copyWith(
                      color: Colors.grey[600],
                    ),
              ),
              const SizedBox(height: 8),
              Text(
                'Use your Apple Pencil to see interactions appear here',
                textAlign: TextAlign.center,
                style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                      color: Colors.grey[500],
                    ),
              ),
            ],
          ),
        ),
      );
    }

    return Card(
      elevation: 2,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Text(
              'Interaction History',
              style: Theme.of(context).textTheme.titleMedium?.copyWith(
                    fontWeight: FontWeight.bold,
                  ),
            ),
          ),
          const Divider(height: 1),
          ListView.separated(
            shrinkWrap: true,
            physics: const NeverScrollableScrollPhysics(),
            itemCount: allEvents.length > 10 ? 10 : allEvents.length,
            separatorBuilder: (context, index) => const Divider(height: 1),
            itemBuilder: (context, index) {
              final event = allEvents[index];
              if (event is PencilTap) {
                return _buildTapListItem(event);
              } else if (event is PencilSqueeze) {
                return _buildSqueezeListItem(event);
              }
              return const SizedBox.shrink();
            },
          ),
        ],
      ),
    );
  }

  Widget _buildTapListItem(PencilTap tap) {
    return ListTile(
      leading: CircleAvatar(
        backgroundColor: Colors.blue[100],
        child: const Icon(Icons.touch_app, color: Colors.blue, size: 20),
      ),
      title: const Text(
        'Double-Tap',
        style: TextStyle(fontWeight: FontWeight.w500),
      ),
      subtitle: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(_formatTimestamp(tap.timestamp)),
          if (tap.hoverPose != null)
            Text(
              'Hover: z=${tap.hoverPose!.zOffset.toStringAsFixed(2)}',
              style: TextStyle(fontSize: 11, color: Colors.grey[600]),
            ),
        ],
      ),
      trailing: Text(
        _getTimeAgo(tap.timestamp),
        style: Theme.of(context).textTheme.bodySmall?.copyWith(
              color: Colors.grey[600],
            ),
      ),
    );
  }

  Widget _buildSqueezeListItem(PencilSqueeze squeeze) {
    return ListTile(
      leading: CircleAvatar(
        backgroundColor: Colors.purple[100],
        child: const Icon(Icons.gesture, color: Colors.purple, size: 20),
      ),
      title: Row(
        children: [
          const Text(
            'Squeeze',
            style: TextStyle(fontWeight: FontWeight.w500),
          ),
          const SizedBox(width: 8),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
            decoration: BoxDecoration(
              color: _getPhaseColor(squeeze.phase),
              borderRadius: BorderRadius.circular(4),
            ),
            child: Text(
              squeeze.phase.name.toUpperCase(),
              style: const TextStyle(
                fontSize: 10,
                color: Colors.white,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        ],
      ),
      subtitle: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(_formatTimestamp(squeeze.timestamp)),
          if (squeeze.hoverPose != null)
            Text(
              'Hover: z=${squeeze.hoverPose!.zOffset.toStringAsFixed(2)}',
              style: TextStyle(fontSize: 11, color: Colors.grey[600]),
            ),
        ],
      ),
      trailing: Text(
        _getTimeAgo(squeeze.timestamp),
        style: Theme.of(context).textTheme.bodySmall?.copyWith(
              color: Colors.grey[600],
            ),
      ),
    );
  }

  void _showPreferences() {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('User Preferences'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            _buildPreferenceItem(
              'Preferred Tap Action',
              _preferredActionName(_preferredTapAction),
            ),
            const SizedBox(height: 12),
            _buildPreferenceItem(
              'Preferred Squeeze Action',
              _preferredActionName(_preferredSqueezeAction),
            ),
            const SizedBox(height: 12),
            _buildPreferenceItem(
              'Pencil Only Drawing',
              _prefersPencilOnlyDrawing ? 'Enabled' : 'Disabled',
            ),
            const SizedBox(height: 12),
            _buildPreferenceItem(
              'Hover Tool Preview',
              _prefersHoverToolPreview ? 'Enabled' : 'Disabled',
            ),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Close'),
          ),
        ],
      ),
    );
  }

  Widget _buildPreferenceItem(String label, String value) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          label,
          style: const TextStyle(
            fontSize: 12,
            color: Colors.grey,
          ),
        ),
        const SizedBox(height: 4),
        Text(
          value,
          style: const TextStyle(
            fontSize: 16,
            fontWeight: FontWeight.bold,
          ),
        ),
      ],
    );
  }

  String _preferredActionName(PencilPreferredAction? action) {
    if (action == null) return 'Unknown';

    switch (action) {
      case PencilPreferredAction.ignore:
        return 'Ignore';
      case PencilPreferredAction.switchEraser:
        return 'Switch Eraser';
      case PencilPreferredAction.switchPrevious:
        return 'Switch Previous';
      case PencilPreferredAction.showColorPalette:
        return 'Color Palette';
      case PencilPreferredAction.showInkAttributes:
        return 'Ink Attributes';
      case PencilPreferredAction.showContextualPalette:
        return 'Contextual Palette';
      case PencilPreferredAction.runSystemShortcut:
        return 'System Shortcut';
    }
  }

  String _formatTimestamp(DateTime timestamp) {
    return '${timestamp.hour.toString().padLeft(2, '0')}:'
        '${timestamp.minute.toString().padLeft(2, '0')}:'
        '${timestamp.second.toString().padLeft(2, '0')}.'
        '${(timestamp.millisecond ~/ 10).toString().padLeft(2, '0')}';
  }

  String _getTimeAgo(DateTime timestamp) {
    final difference = DateTime.now().difference(timestamp);

    if (difference.inSeconds < 60) {
      return '${difference.inSeconds}s ago';
    } else if (difference.inMinutes < 60) {
      return '${difference.inMinutes}m ago';
    } else {
      return '${difference.inHours}h ago';
    }
  }

  Color _getPhaseColor(PencilInteractionPhase phase) {
    switch (phase) {
      case PencilInteractionPhase.began:
        return Colors.green;
      case PencilInteractionPhase.changed:
        return Colors.orange;
      case PencilInteractionPhase.ended:
        return Colors.blue;
      case PencilInteractionPhase.cancelled:
        return Colors.red;
    }
  }

  double _radiansToDegrees(double radians) {
    return radians * 180 / math.pi;
  }
}
2
likes
150
points
23
downloads

Publisher

verified publisherfilippofinke.ch

Weekly Downloads

A Flutter plugin for detecting Apple Pencil interactions including double-tap and squeeze gestures.

Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on flutter_apple_pencil

Packages that implement flutter_apple_pencil