haptic_kit 1.0.0
haptic_kit: ^1.0.0 copied to clipboard
Haptic feedback, vibration and animated UI widgets for Flutter — Android & iOS. Impact, notification, selection, predefined effects, custom waveforms, Core Haptics patterns plus 8 production-ready widgets.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:haptic_kit/haptic_kit.dart';
void main() => runApp(const VibrationDemoApp());
class VibrationDemoApp extends StatelessWidget {
const VibrationDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Vibration & Haptics Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.indigo,
brightness: Brightness.dark,
),
home: const VibrationDemoPage(),
);
}
}
class VibrationDemoPage extends StatelessWidget {
const VibrationDemoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('haptic_kit'),
centerTitle: true,
),
body: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
children: const [
_TypewriterDemo(),
SizedBox(height: 32),
_HeartbeatDemo(),
SizedBox(height: 32),
_LoadingRampDemo(),
SizedBox(height: 32),
_BouncyPressDemo(),
SizedBox(height: 32),
_UnboxingDemo(),
SizedBox(height: 32),
_ToggleDemo(),
SizedBox(height: 32),
_SliderDemo(),
SizedBox(height: 32),
_StepperDemo(),
SizedBox(height: 32),
_ShakeDemo(),
SizedBox(height: 32),
_SlideToConfirmDemo(),
SizedBox(height: 32),
_RatingDemo(),
SizedBox(height: 40),
Divider(),
SizedBox(height: 16),
_RawApiSection(),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Demo 1 — Typewriter with per-character tick haptic
// ---------------------------------------------------------------------------
class _TypewriterDemo extends StatefulWidget {
const _TypewriterDemo();
@override
State<_TypewriterDemo> createState() => _TypewriterDemoState();
}
class _TypewriterDemoState extends State<_TypewriterDemo> {
static const String _phrase =
'Each keystroke is a tiny haptic tick — feel it.';
static const Duration _charDelay = Duration(milliseconds: 75);
String _typed = '';
Timer? _timer;
bool _running = false;
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _start() {
_timer?.cancel();
setState(() {
_typed = '';
_running = true;
});
var i = 0;
_timer = Timer.periodic(_charDelay, (timer) {
if (i >= _phrase.length) {
timer.cancel();
setState(() => _running = false);
Haptics.notification(HapticNotificationStyle.success);
return;
}
final char = _phrase[i];
i++;
setState(() => _typed += char);
if (char != ' ') {
Haptics.selection();
}
});
}
@override
Widget build(BuildContext context) {
return _DemoCard(
title: 'Typewriter',
subtitle: 'Selection haptic on every character',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
constraints: const BoxConstraints(minHeight: 80),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(12),
),
width: double.infinity,
child: Text(
_typed.isEmpty ? '_' : '$_typed${_running ? '|' : ''}',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 16,
height: 1.4,
),
),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _running ? null : _start,
icon: const Icon(Icons.keyboard),
label: Text(_running ? 'Typing…' : 'Start typing'),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Demo 2 — Heartbeat with synced pulsing heart
// ---------------------------------------------------------------------------
class _HeartbeatDemo extends StatefulWidget {
const _HeartbeatDemo();
@override
State<_HeartbeatDemo> createState() => _HeartbeatDemoState();
}
class _HeartbeatDemoState extends State<_HeartbeatDemo>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scale;
bool _running = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
);
// Two-pulse heartbeat — matches VibrationPatterns.heartbeat timings.
_scale = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(begin: 1.0, end: 1.35)
.chain(CurveTween(curve: Curves.easeOut)),
weight: 10,
),
TweenSequenceItem(
tween: Tween(begin: 1.35, end: 1.0)
.chain(CurveTween(curve: Curves.easeIn)),
weight: 10,
),
TweenSequenceItem(
tween: Tween(begin: 1.0, end: 1.35)
.chain(CurveTween(curve: Curves.easeOut)),
weight: 10,
),
TweenSequenceItem(
tween: Tween(begin: 1.35, end: 1.0)
.chain(CurveTween(curve: Curves.easeIn)),
weight: 10,
),
TweenSequenceItem(tween: ConstantTween(1.0), weight: 60),
]).animate(_controller);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _beatOnce() async {
setState(() => _running = true);
await Future.wait([
VibrationPatterns.heartbeat(),
_controller.forward(from: 0),
]);
if (mounted) setState(() => _running = false);
}
@override
Widget build(BuildContext context) {
return _DemoCard(
title: 'Heartbeat',
subtitle: 'Vibration waveform synced with a pulsing heart',
child: Column(
children: [
SizedBox(
height: 140,
child: Center(
child: ScaleTransition(
scale: _scale,
child: Icon(
Icons.favorite,
size: 96,
color: Colors.red.shade400,
),
),
),
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: _running ? null : _beatOnce,
icon: const Icon(Icons.play_arrow),
label: const Text('Beat'),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Demo 3 — Loading bar 0→100% with haptic ramp light → medium → heavy
// ---------------------------------------------------------------------------
class _LoadingRampDemo extends StatefulWidget {
const _LoadingRampDemo();
@override
State<_LoadingRampDemo> createState() => _LoadingRampDemoState();
}
class _LoadingRampDemoState extends State<_LoadingRampDemo>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
// Threshold → haptic style. Crossing each threshold triggers exactly once.
// Not const because Dart forbids `double` as a const map key.
static final List<(double, HapticImpactStyle)> _ticks = [
(0.25, HapticImpactStyle.light),
(0.50, HapticImpactStyle.medium),
(0.75, HapticImpactStyle.heavy),
];
final Set<double> _fired = {};
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2200),
)..addListener(_onTick);
}
@override
void dispose() {
_controller
..removeListener(_onTick)
..dispose();
super.dispose();
}
void _onTick() {
final value = _controller.value;
for (final (threshold, style) in _ticks) {
if (value >= threshold && _fired.add(threshold)) {
Haptics.impact(style);
}
}
if (value >= 1.0 && _fired.add(1.0)) {
Haptics.notification(HapticNotificationStyle.success);
}
}
Future<void> _start() async {
_fired.clear();
await _controller.forward(from: 0);
}
@override
Widget build(BuildContext context) {
return _DemoCard(
title: 'Loading ramp',
subtitle: 'Ticks at 25 / 50 / 75% → success at 100%',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final v = _controller.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
value: v,
minHeight: 14,
backgroundColor: Colors.white12,
valueColor: AlwaysStoppedAnimation(
Color.lerp(
Colors.lightBlueAccent,
Colors.deepOrangeAccent,
v,
)!,
),
),
),
const SizedBox(height: 8),
Text(
'${(v * 100).round()}%',
style: Theme.of(context).textTheme.titleMedium,
),
],
);
},
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _controller.isAnimating ? null : _start,
icon: const Icon(Icons.refresh),
label: const Text('Start loading'),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Demo 4 — Bouncy press: scale-down on press, spring-bounce on release
// ---------------------------------------------------------------------------
class _BouncyPressDemo extends StatelessWidget {
const _BouncyPressDemo();
@override
Widget build(BuildContext context) {
return const _DemoCard(
title: 'Bouncy press (HapticBounce)',
subtitle: '3-phase TweenSequence: squash → recoil → elastic settle. '
'Light haptic on press, medium on release.',
child: Center(
child: HapticBounce(
child: _BouncyOrb(),
),
),
);
}
}
class _BouncyOrb extends StatelessWidget {
const _BouncyOrb();
@override
Widget build(BuildContext context) {
return Container(
width: 180,
height: 180,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF7C4DFF), Color(0xFFFF4081)],
),
boxShadow: [
BoxShadow(
color: Colors.purpleAccent.withValues(alpha: 0.4),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: const Center(
child: Text(
'Press me',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
);
}
}
// ---------------------------------------------------------------------------
// Demo 5 — Press & hold to unbox (PressAndHoldToConfirm)
// ---------------------------------------------------------------------------
class _UnboxingDemo extends StatefulWidget {
const _UnboxingDemo();
@override
State<_UnboxingDemo> createState() => _UnboxingDemoState();
}
class _UnboxingDemoState extends State<_UnboxingDemo> {
final _confirmKey = GlobalKey<PressAndHoldToConfirmState>();
bool _opened = false;
void _onConfirm() {
setState(() => _opened = true);
// Auto-reset after a moment so the demo can be replayed.
Future<void>.delayed(const Duration(seconds: 3), () {
if (!mounted) return;
setState(() => _opened = false);
_confirmKey.currentState?.reset();
});
}
@override
Widget build(BuildContext context) {
return _DemoCard(
title: 'Press & hold to unbox',
subtitle: '12 haptic ticks at densifying intervals — selection → light → '
'medium → heavy → final heavy on completion.',
child: SizedBox(
height: 240,
child: PressAndHoldToConfirm(
key: _confirmKey,
holdDuration: const Duration(seconds: 2),
onConfirm: _onConfirm,
ringSize: 84,
ringColor: Colors.amberAccent,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF1A1A2E), Color(0xFF16213E)],
),
),
child: Center(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
transitionBuilder: (child, animation) => ScaleTransition(
scale: CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
),
child: FadeTransition(opacity: animation, child: child),
),
child: _opened
? const Column(
key: ValueKey('opened'),
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.celebration,
size: 96,
color: Colors.amberAccent,
),
SizedBox(height: 8),
Text(
'Unboxed!',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
],
)
: const Column(
key: ValueKey('closed'),
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.card_giftcard,
size: 96,
color: Colors.amberAccent,
),
SizedBox(height: 8),
Text(
'Press and hold',
style: TextStyle(
fontSize: 14,
color: Colors.white70,
),
),
],
),
),
),
),
),
),
);
}
}
// ---------------------------------------------------------------------------
// Demo 6 — HapticToggle
// ---------------------------------------------------------------------------
class _ToggleDemo extends StatefulWidget {
const _ToggleDemo();
@override
State<_ToggleDemo> createState() => _ToggleDemoState();
}
class _ToggleDemoState extends State<_ToggleDemo> {
bool _on = false;
@override
Widget build(BuildContext context) {
return _DemoCard(
title: 'HapticToggle',
subtitle: 'Animated thumb + selection tick on every flip',
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Notifications', style: TextStyle(fontSize: 16)),
HapticToggle(
value: _on,
onChanged: (v) => setState(() => _on = v),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Demo 7 — HapticSlider
// ---------------------------------------------------------------------------
class _SliderDemo extends StatefulWidget {
const _SliderDemo();
@override
State<_SliderDemo> createState() => _SliderDemoState();
}
class _SliderDemoState extends State<_SliderDemo> {
double _v = 30;
@override
Widget build(BuildContext context) {
return _DemoCard(
title: 'HapticSlider',
subtitle: 'Light tick at every detent — drag to feel them',
child: Column(
children: [
HapticSlider(
value: _v,
min: 0,
max: 100,
divisions: 10,
onChanged: (v) => setState(() => _v = v),
),
Text('${_v.round()}', style: const TextStyle(fontSize: 18)),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Demo 8 — HapticStepper
// ---------------------------------------------------------------------------
class _StepperDemo extends StatefulWidget {
const _StepperDemo();
@override
State<_StepperDemo> createState() => _StepperDemoState();
}
class _StepperDemoState extends State<_StepperDemo> {
int _count = 1;
@override
Widget build(BuildContext context) {
return _DemoCard(
title: 'HapticStepper',
subtitle:
'Bouncy −/+ buttons + sliding number. Heavy tick at boundaries.',
child: Center(
child: HapticStepper(
value: _count,
min: 0,
max: 9,
onChanged: (v) => setState(() => _count = v),
),
),
);
}
}
// ---------------------------------------------------------------------------
// Demo 9 — HapticShake (wrong password feel)
// ---------------------------------------------------------------------------
class _ShakeDemo extends StatefulWidget {
const _ShakeDemo();
@override
State<_ShakeDemo> createState() => _ShakeDemoState();
}
class _ShakeDemoState extends State<_ShakeDemo> {
final _shakeKey = GlobalKey<HapticShakeState>();
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _check() {
if (_controller.text == '1234') {
Haptics.notification(HapticNotificationStyle.success);
} else {
_shakeKey.currentState?.shake();
}
}
@override
Widget build(BuildContext context) {
return _DemoCard(
title: 'HapticShake',
subtitle: 'Type anything but "1234" and tap "Submit" to feel the error.',
child: Column(
children: [
HapticShake(
key: _shakeKey,
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'PIN',
border: OutlineInputBorder(),
),
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: _check,
child: const Text('Submit'),
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Demo 10 — SlideToConfirm
// ---------------------------------------------------------------------------
class _SlideToConfirmDemo extends StatefulWidget {
const _SlideToConfirmDemo();
@override
State<_SlideToConfirmDemo> createState() => _SlideToConfirmDemoState();
}
class _SlideToConfirmDemoState extends State<_SlideToConfirmDemo> {
final _slideKey = GlobalKey<SlideToConfirmState>();
String? _last;
@override
Widget build(BuildContext context) {
return _DemoCard(
title: 'SlideToConfirm',
subtitle: 'Drag the handle all the way right. Ticks at 25/50/75%, '
'heavy thump on completion.',
child: Column(
children: [
SlideToConfirm(
key: _slideKey,
label: 'Slide to pay',
onConfirmed: () => setState(() => _last = 'Confirmed!'),
),
if (_last != null) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_last!, style: const TextStyle(color: Colors.greenAccent)),
TextButton(
onPressed: () {
setState(() => _last = null);
_slideKey.currentState?.reset();
},
child: const Text('Reset'),
),
],
),
],
],
),
);
}
}
// ---------------------------------------------------------------------------
// Demo 11 — HapticRating (cascading stars)
// ---------------------------------------------------------------------------
class _RatingDemo extends StatefulWidget {
const _RatingDemo();
@override
State<_RatingDemo> createState() => _RatingDemoState();
}
class _RatingDemoState extends State<_RatingDemo> {
int _rating = 0;
@override
Widget build(BuildContext context) {
return _DemoCard(
title: 'HapticRating',
subtitle: 'Tap a star — all stars cascade-fill with a tick each',
child: Column(
children: [
Center(
child: HapticRating(
value: _rating,
starCount: 5,
onChanged: (v) => setState(() => _rating = v),
),
),
const SizedBox(height: 8),
Text(
_rating == 0 ? 'No rating' : '$_rating / 5',
style: const TextStyle(color: Colors.white60),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Raw API section — direct access to every endpoint, no animation
// ---------------------------------------------------------------------------
class _RawApiSection extends StatefulWidget {
const _RawApiSection();
@override
State<_RawApiSection> createState() => _RawApiSectionState();
}
class _RawApiSectionState extends State<_RawApiSection> {
HapticCapabilities? _capabilities;
@override
void initState() {
super.initState();
HapticCapabilities.query().then(
(c) => mounted ? setState(() => _capabilities = c) : null,
);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Raw API', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
if (_capabilities != null) _CapabilitiesCard(_capabilities!),
const SizedBox(height: 16),
_ChipRow(
label: 'Impact',
children: [
for (final s in HapticImpactStyle.values)
ActionChip(
label: Text(s.name),
onPressed: () => Haptics.impact(s),
),
],
),
_ChipRow(
label: 'Notification',
children: [
for (final s in HapticNotificationStyle.values)
ActionChip(
label: Text(s.name),
onPressed: () => Haptics.notification(s),
),
],
),
_ChipRow(
label: 'Predefined',
children: [
for (final e in PredefinedEffect.values)
ActionChip(
label: Text(e.name),
onPressed: () => Vibration.playPredefined(e),
),
],
),
_ChipRow(
label: 'Patterns',
children: [
ActionChip(
label: const Text('alarm'),
onPressed: () => VibrationPatterns.alarm(repeat: false),
),
const ActionChip(
label: Text('success'),
onPressed: VibrationPatterns.success,
),
const ActionChip(
label: Text('failure'),
onPressed: VibrationPatterns.failure,
),
const ActionChip(
label: Text('charge up'),
onPressed: VibrationPatterns.chargeUp,
),
const ActionChip(
label: Text('cancel'),
onPressed: Vibration.cancel,
),
],
),
],
);
}
}
// ---------------------------------------------------------------------------
// Shared widgets
// ---------------------------------------------------------------------------
class _DemoCard extends StatelessWidget {
const _DemoCard({
required this.title,
required this.subtitle,
required this.child,
});
final String title;
final String subtitle;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
color: Colors.white.withValues(alpha: 0.04),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(color: Colors.white.withValues(alpha: 0.06)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 4),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.white60,
),
),
const SizedBox(height: 16),
child,
],
),
),
);
}
}
class _ChipRow extends StatelessWidget {
const _ChipRow({required this.label, required this.children});
final String label;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 6),
Wrap(spacing: 8, runSpacing: 4, children: children),
],
),
);
}
}
class _CapabilitiesCard extends StatelessWidget {
const _CapabilitiesCard(this.caps);
final HapticCapabilities caps;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Device capabilities',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 6),
_row('Vibrator', caps.hasVibrator),
_row('Amplitude control', caps.hasAmplitudeControl),
_row('Custom patterns', caps.supportsCustomPatterns),
_row('Predefined effects', caps.supportsPredefinedEffects),
_row('Impact feedback', caps.supportsImpactFeedback),
],
),
);
}
Widget _row(String label, bool value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
Icon(
value ? Icons.check_circle : Icons.cancel,
size: 14,
color: value ? Colors.greenAccent : Colors.white24,
),
const SizedBox(width: 8),
Text(label, style: const TextStyle(fontSize: 13)),
],
),
);
}