diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml
index ccab1f7..fa41eac 100644
--- a/.github/workflows/dart.yml
+++ b/.github/workflows/dart.yml
@@ -7,7 +7,7 @@ on:
branches: [ "main" ]
env:
- FLUTTER_VERSION: "3.32.3"
+ FLUTTER_VERSION: "3.35.0"
jobs:
discover:
diff --git a/README.md b/README.md
index ab0e253..2858b19 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,8 @@ A place where Flutter packages are crafted and built.
| [](https://pub.dev/packages/ns_utils) |
| [](https://pub.dev/packages/ns_intl_phone_input) |
| [](https://pub.dev/packages/dzod) |
-| [](https://pub.dev/packages/html_rich_text) |
+| [](https://pub.dev/packages/html_rich_text) |
+| [](https://pub.dev/packages/morse_tap) |
| Plugins |
|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
diff --git a/packages/README.md b/packages/README.md
index 99eb520..d324fe5 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -23,3 +23,4 @@
| ns_intl_phone_input | [](https://pub.dev/packages/ns_intl_phone_input) | [`🔗`](ns_intl_phone_input/README.md) |
| html_rich_text | [](https://pub.dev/packages/html_rich_text) | [`🔗`](html_rich_text/README.md) |
| dzod | [](https://pub.dev/packages/dzod) | [`🔗`](dzod/README.md) |
+| morse_tap | [](https://pub.dev/packages/morse_tap) | [`🔗`](morse_tap/README.md) |
diff --git a/packages/morse_tap/.gitignore b/packages/morse_tap/.gitignore
new file mode 100644
index 0000000..eb6c05c
--- /dev/null
+++ b/packages/morse_tap/.gitignore
@@ -0,0 +1,31 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.buildlog/
+.history
+.svn/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
+/pubspec.lock
+**/doc/api/
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+build/
diff --git a/packages/morse_tap/.metadata b/packages/morse_tap/.metadata
new file mode 100644
index 0000000..8c47ce6
--- /dev/null
+++ b/packages/morse_tap/.metadata
@@ -0,0 +1,10 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "d7b523b356d15fb81e7d340bbe52b47f93937323"
+ channel: "stable"
+
+project_type: package
diff --git a/packages/morse_tap/CHANGELOG.md b/packages/morse_tap/CHANGELOG.md
new file mode 100644
index 0000000..f3ac5a3
--- /dev/null
+++ b/packages/morse_tap/CHANGELOG.md
@@ -0,0 +1,21 @@
+## 0.0.3
+
+ - **REFACTOR**(haptic_utils): improve import formatting and ensure newline at EOF.
+ - **FEAT**(morse_tap): add comprehensive platform support for web and desktop.
+
+## 0.0.2
+
+ - **REFACTOR**(morse_tap): update app title and improve layout with ListView.
+ - **REFACTOR**(morse_tap): clean up morse_text_input formatting.
+ - **REFACTOR**(morse_tap): remove visual feedback from MorseTapDetector.
+ - **FIX**(morse_tap): resolve UI overflow in tap detector example.
+ - **FEAT**(morse_tap): integrate haptic feedback and fix UI overflow.
+ - **FEAT**(morse_tap): add haptic feedback system.
+ - **FEAT**(examples): integrate onSequenceChange callback with real-time UI.
+ - **FEAT**(packages): add morse_tap package with gesture-based input.
+ - **DOCS**(morse_tap): add haptic config modal and update documentation.
+ - **DOCS**(morse_tap): add screenshot and improve documentation.
+
+## 0.0.1
+
+* TODO: Describe initial release.
diff --git a/packages/morse_tap/LICENSE b/packages/morse_tap/LICENSE
new file mode 100644
index 0000000..a415433
--- /dev/null
+++ b/packages/morse_tap/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 NonStop IO
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/morse_tap/README.md b/packages/morse_tap/README.md
new file mode 100644
index 0000000..086bfa4
--- /dev/null
+++ b/packages/morse_tap/README.md
@@ -0,0 +1,265 @@
+
+
+
+
+
NonStop
+ Digital Product Development Experts for Startups & Enterprises
+
+ About |
+ Website
+
+
+
+# morse_tap
+
+[](https://github.com/nonstopio/flutter_forge/tree/main/packages/morse_tap)
+[](https://opensource.org/licenses/MIT)
+
+A Flutter package that provides Morse code input functionality using intuitive gestures. Create interactive Morse code experiences with single taps for dots, double taps for dashes, and long presses for spaces.
+
+
+
+## Features
+
+✨ **MorseTapDetector** - Widget that detects specific Morse code patterns using gestures
+🎯 **MorseTextInput** - Real-time gesture-to-text conversion widget
+🔄 **String Extensions** - Convert any string to/from Morse code
+⚡ **Fast Algorithm** - Efficient Morse code conversion with comprehensive character support
+🎨 **Intuitive Gestures** - Single tap = dot, double tap = dash, long press = space
+📳 **Haptic Feedback** - Customizable tactile feedback for enhanced user experience
+
+## Usage Examples
+
+### 1. MorseTapDetector - Pattern Detection
+
+Detect when users input a specific Morse code pattern using gestures:
+
+```dart
+MorseTapDetector(
+ expectedMorseCode: "... --- ...", // SOS pattern
+ onCorrectSequence: () {
+ print("SOS detected!");
+ // Handle correct sequence
+ },
+ onIncorrectSequence: () {
+ print("Wrong pattern, try again");
+ },
+ onSequenceChange: (sequence) {
+ print("Current sequence: $sequence");
+ // Update UI with current input
+ },
+ onDotAdded: () => print("Dot added"),
+ onDashAdded: () => print("Dash added"),
+ onSpaceAdded: () => print("Space added"),
+ child: Container(
+ width: 200,
+ height: 200,
+ decoration: BoxDecoration(
+ color: Colors.blue,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: const Center(
+ child: Text(
+ 'Use Gestures for SOS',
+ style: TextStyle(color: Colors.white, fontSize: 20),
+ ),
+ ),
+ ),
+)
+```
+
+### 2. MorseTextInput - Real-time Conversion
+
+Convert tap input to text in real-time:
+
+```dart
+class MorseInputExample extends StatelessWidget {
+ final TextEditingController controller = TextEditingController();
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ children: [
+ MorseTextInput(
+ controller: controller,
+ autoConvertToText: true,
+ showMorsePreview: true,
+ onTextChanged: (text) {
+ print("Converted text: $text");
+ },
+ decoration: const InputDecoration(
+ labelText: 'Tap to input text',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ // Your converted text appears in the controller
+ TextField(
+ controller: controller,
+ readOnly: true,
+ decoration: const InputDecoration(
+ labelText: 'Output',
+ ),
+ ),
+ ],
+ );
+ }
+}
+```
+
+### 3. String Extensions
+
+Easy string to Morse code conversion:
+
+```dart
+// Convert text to Morse code
+String morse = "HELLO WORLD".toMorseCode();
+print(morse); // ".... . .-.. .-.. --- / .-- --- .-. .-.. -.."
+
+// Convert Morse code back to text
+String text = "... --- ...".fromMorseCode();
+print(text); // "SOS"
+
+// Validate Morse input
+bool isValid = "... --- ...".isValidMorseSequence();
+print(isValid); // true
+
+// Check if string contains only Morse characters
+bool isMorseInput = "... abc".isValidMorseInput();
+print(isMorseInput); // false
+```
+
+## Configuration
+
+### Timing Configuration
+
+Customize the input timeout:
+
+```dart
+MorseTapDetector(
+ expectedMorseCode: "... --- ...",
+ inputTimeout: Duration(seconds: 5), // Time allowed for next input
+ onCorrectSequence: () => print("Correct!"),
+ child: MyButton(),
+)
+```
+
+Note: The timeout resets after each input, allowing users to take their time
+with long sequences as long as they keep entering characters.
+
+### Haptic Feedback
+
+Provide tactile feedback for gestures:
+
+```dart
+MorseTapDetector(
+ expectedMorseCode: "... --- ...",
+ hapticConfig: HapticConfig.defaultConfig, // Enable haptic feedback
+ onCorrectSequence: () => print("Correct!"),
+ child: MyButton(),
+)
+```
+
+**Preset configurations:**
+```dart
+// Different preset options
+HapticConfig.disabled // No haptic feedback
+HapticConfig.light // Subtle feedback
+HapticConfig.defaultConfig // Moderate feedback
+HapticConfig.strong // Intense feedback
+
+// Custom configuration
+HapticConfig(
+ enabled: true,
+ dotIntensity: HapticFeedbackType.lightImpact,
+ dashIntensity: HapticFeedbackType.mediumImpact,
+ correctSequenceIntensity: HapticFeedbackType.heavyImpact,
+)
+```
+
+### Visual Feedback
+
+Control visual feedback options:
+
+```dart
+MorseTextInput(
+ controller: controller,
+ showMorsePreview: true, // Show Morse preview
+ feedbackColor: Colors.green, // Tap feedback color
+ tapAreaHeight: 150.0, // Height of tap area
+ autoConvertToText: false, // Keep as Morse code
+)
+```
+
+## Supported Characters
+
+The package supports:
+- **Letters**: A-Z (26 letters)
+- **Numbers**: 0-9 (10 digits)
+- **Punctuation**: . , ? ' ! / ( ) & : ; = + - _ " $ @
+
+*See the complete mapping in `MorseCodec` class documentation.*
+
+## Advanced Usage
+
+### Custom Morse Patterns
+
+Create custom pattern detection:
+
+```dart
+final customPattern = "HELP".toMorseCode();
+
+MorseTapDetector(
+ expectedMorseCode: customPattern,
+ onCorrectSequence: () => showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: Text("Help Requested!"),
+ content: Text("Someone needs assistance."),
+ ),
+ ),
+ child: EmergencyButton(),
+)
+```
+
+## Contributing
+
+We welcome contributions in various forms:
+
+- Proposing new features or enhancements.
+- Reporting and fixing bugs.
+- Engaging in discussions to help make decisions.
+- Improving documentation, as it is essential.
+- Sending Pull Requests is greatly appreciated!
+
+---
+
+
+
+
+**Stay connected and get the latest updates!**
+
+[](https://www.linkedin.com/company/nonstop-io)
+[](https://x.com/NonStopio)
+[](https://www.instagram.com/nonstopio_technologies/)
+[](https://www.youtube.com/@NonStopioTechnology)
+[](mailto:contact@nonstopio.com)
+
+
+
+---
+
+
+
+ ⭐ Star us on [GitHub](https://github.com/nonstopio/flutter_forge) if this helped you!
+
+
+
+## 📜 License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+
+
+ 🎉 [Founded by Ajay Kumar](https://github.com/ProjectAJ14) 🎉**
+
+
diff --git a/packages/morse_tap/example/.gitignore b/packages/morse_tap/example/.gitignore
new file mode 100644
index 0000000..4711f1d
--- /dev/null
+++ b/packages/morse_tap/example/.gitignore
@@ -0,0 +1,52 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
+
+android
+ios
+linux
+macos
+web
+windows
diff --git a/packages/morse_tap/example/.metadata b/packages/morse_tap/example/.metadata
new file mode 100644
index 0000000..d1512eb
--- /dev/null
+++ b/packages/morse_tap/example/.metadata
@@ -0,0 +1,45 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "b8962555571d8c170cff8e76023ea7bf60e5ec4b"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: b8962555571d8c170cff8e76023ea7bf60e5ec4b
+ base_revision: b8962555571d8c170cff8e76023ea7bf60e5ec4b
+ - platform: android
+ create_revision: b8962555571d8c170cff8e76023ea7bf60e5ec4b
+ base_revision: b8962555571d8c170cff8e76023ea7bf60e5ec4b
+ - platform: ios
+ create_revision: b8962555571d8c170cff8e76023ea7bf60e5ec4b
+ base_revision: b8962555571d8c170cff8e76023ea7bf60e5ec4b
+ - platform: linux
+ create_revision: b8962555571d8c170cff8e76023ea7bf60e5ec4b
+ base_revision: b8962555571d8c170cff8e76023ea7bf60e5ec4b
+ - platform: macos
+ create_revision: b8962555571d8c170cff8e76023ea7bf60e5ec4b
+ base_revision: b8962555571d8c170cff8e76023ea7bf60e5ec4b
+ - platform: web
+ create_revision: b8962555571d8c170cff8e76023ea7bf60e5ec4b
+ base_revision: b8962555571d8c170cff8e76023ea7bf60e5ec4b
+ - platform: windows
+ create_revision: b8962555571d8c170cff8e76023ea7bf60e5ec4b
+ base_revision: b8962555571d8c170cff8e76023ea7bf60e5ec4b
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/packages/morse_tap/example/README.md b/packages/morse_tap/example/README.md
new file mode 100644
index 0000000..2b3fce4
--- /dev/null
+++ b/packages/morse_tap/example/README.md
@@ -0,0 +1,16 @@
+# example
+
+A new Flutter project.
+
+## Getting Started
+
+This project is a starting point for a Flutter application.
+
+A few resources to get you started if this is your first Flutter project:
+
+- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
+- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
+
+For help getting started with Flutter development, view the
+[online documentation](https://docs.flutter.dev/), which offers tutorials,
+samples, guidance on mobile development, and a full API reference.
diff --git a/packages/morse_tap/example/analysis_options.yaml b/packages/morse_tap/example/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/packages/morse_tap/example/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/packages/morse_tap/example/lib/haptic_config_modal.dart b/packages/morse_tap/example/lib/haptic_config_modal.dart
new file mode 100644
index 0000000..1c3e858
--- /dev/null
+++ b/packages/morse_tap/example/lib/haptic_config_modal.dart
@@ -0,0 +1,276 @@
+import 'package:flutter/material.dart';
+import 'package:morse_tap/morse_tap.dart';
+
+/// A modal dialog for configuring haptic feedback settings.
+///
+/// Provides an intuitive interface for users to customize haptic feedback
+/// intensity for different Morse code gestures and events.
+class HapticConfigModal extends StatefulWidget {
+ /// Creates a haptic configuration modal.
+ const HapticConfigModal({
+ super.key,
+ required this.initialConfig,
+ this.onConfigChanged,
+ });
+
+ /// The initial haptic configuration to display
+ final HapticConfig initialConfig;
+
+ /// Callback when configuration changes are applied
+ final ValueChanged? onConfigChanged;
+
+ @override
+ State createState() => _HapticConfigModalState();
+
+ /// Shows the haptic configuration modal.
+ ///
+ /// Returns the new configuration if saved, or null if canceled.
+ static Future show(
+ BuildContext context, {
+ required HapticConfig initialConfig,
+ }) {
+ return showDialog(
+ context: context,
+ builder: (context) => HapticConfigModal(initialConfig: initialConfig),
+ );
+ }
+}
+
+class _HapticConfigModalState extends State {
+ late HapticConfig _currentConfig;
+ String? _selectedPreset;
+
+ @override
+ void initState() {
+ super.initState();
+ _currentConfig = widget.initialConfig;
+ _selectedPreset = HapticUtils.getPresetName(_currentConfig);
+ }
+
+ void _updateConfig(HapticConfig newConfig) {
+ setState(() {
+ _currentConfig = newConfig;
+ _selectedPreset = HapticUtils.getPresetName(newConfig);
+ });
+ }
+
+ void _applyPreset(String presetName) {
+ final preset = HapticUtils.presetConfigs[presetName];
+ if (preset != null) {
+ _updateConfig(preset);
+ }
+ }
+
+ void _resetToDefaults() {
+ _updateConfig(HapticConfig.defaultConfig);
+ }
+
+ void _saveAndClose() {
+ widget.onConfigChanged?.call(_currentConfig);
+ Navigator.of(context).pop(_currentConfig);
+ }
+
+ void _cancel() {
+ Navigator.of(context).pop();
+ }
+
+ Widget _buildEnabledSwitch() {
+ return SwitchListTile(
+ title: const Text('Enable Haptic Feedback'),
+ subtitle: Text(
+ HapticUtils.isHapticSupported
+ ? 'Provide tactile feedback for gestures'
+ : 'Not supported on this platform',
+ ),
+ value: _currentConfig.enabled,
+ onChanged: HapticUtils.isHapticSupported
+ ? (enabled) =>
+ _updateConfig(_currentConfig.copyWith(enabled: enabled))
+ : null,
+ );
+ }
+
+ Widget _buildPresetSelector() {
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Quick Presets',
+ style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
+ ),
+ const SizedBox(height: 12),
+ Wrap(
+ spacing: 8,
+ children: HapticUtils.presetConfigs.entries.map((entry) {
+ final isSelected = _selectedPreset == entry.key;
+ return FilterChip(
+ label: Text(entry.key),
+ selected: isSelected,
+ onSelected: (_) => _applyPreset(entry.key),
+ );
+ }).toList(),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildHapticSetting({
+ required String title,
+ required String description,
+ required HapticFeedbackType currentType,
+ required ValueChanged onChanged,
+ }) {
+ return Card(
+ margin: const EdgeInsets.only(bottom: 8.0),
+ child: Padding(
+ padding: const EdgeInsets.all(12.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: const TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ Text(
+ description,
+ style: TextStyle(fontSize: 13, color: Colors.grey[600]),
+ ),
+ ],
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.touch_app, size: 20),
+ tooltip: 'Test haptic',
+ padding: EdgeInsets.zero,
+ constraints: const BoxConstraints(
+ minWidth: 32,
+ minHeight: 32,
+ ),
+ onPressed:
+ _currentConfig.enabled && HapticUtils.isHapticSupported
+ ? () => HapticUtils.testHaptic(currentType)
+ : null,
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ DropdownButtonFormField(
+ initialValue: currentType,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(),
+ isDense: true,
+ contentPadding: EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 8,
+ ),
+ ),
+ items: HapticUtils.availableHapticTypes
+ .map(HapticUtils.createDropdownItem)
+ .toList(),
+ onChanged: _currentConfig.enabled ? onChanged : null,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ title: const Text('Haptic Feedback Settings'),
+ contentPadding: const EdgeInsets.fromLTRB(24.0, 16.0, 24.0, 0.0),
+ content: SizedBox(
+ width: double.maxFinite,
+ height: MediaQuery.of(context).size.height * 0.6, // Constrain height
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _buildEnabledSwitch(),
+ const SizedBox(height: 12),
+
+ if (_currentConfig.enabled) ...[
+ _buildPresetSelector(),
+ const SizedBox(height: 12),
+
+ _buildHapticSetting(
+ title: 'Dots (Single Tap)',
+ description: 'Haptic feedback for dot inputs',
+ currentType: _currentConfig.dotIntensity,
+ onChanged: (type) => _updateConfig(
+ _currentConfig.copyWith(dotIntensity: type),
+ ),
+ ),
+
+ _buildHapticSetting(
+ title: 'Dashes (Double Tap)',
+ description: 'Haptic feedback for dash inputs',
+ currentType: _currentConfig.dashIntensity,
+ onChanged: (type) => _updateConfig(
+ _currentConfig.copyWith(dashIntensity: type),
+ ),
+ ),
+
+ _buildHapticSetting(
+ title: 'Spaces (Long Press)',
+ description: 'Haptic feedback for space inputs',
+ currentType: _currentConfig.spaceIntensity,
+ onChanged: (type) => _updateConfig(
+ _currentConfig.copyWith(spaceIntensity: type),
+ ),
+ ),
+
+ _buildHapticSetting(
+ title: 'Correct Sequence',
+ description: 'Haptic feedback for successful completion',
+ currentType: _currentConfig.correctSequenceIntensity,
+ onChanged: (type) => _updateConfig(
+ _currentConfig.copyWith(correctSequenceIntensity: type),
+ ),
+ ),
+
+ _buildHapticSetting(
+ title: 'Incorrect Sequence',
+ description: 'Haptic feedback for errors',
+ currentType: _currentConfig.incorrectSequenceIntensity,
+ onChanged: (type) => _updateConfig(
+ _currentConfig.copyWith(incorrectSequenceIntensity: type),
+ ),
+ ),
+
+ _buildHapticSetting(
+ title: 'Input Timeout',
+ description: 'Haptic feedback for timeout events',
+ currentType: _currentConfig.timeoutIntensity,
+ onChanged: (type) => _updateConfig(
+ _currentConfig.copyWith(timeoutIntensity: type),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ actions: [
+ TextButton(onPressed: _resetToDefaults, child: const Text('Reset')),
+ TextButton(onPressed: _cancel, child: const Text('Cancel')),
+ ElevatedButton(onPressed: _saveAndClose, child: const Text('Save')),
+ ],
+ );
+ }
+}
diff --git a/packages/morse_tap/example/lib/main.dart b/packages/morse_tap/example/lib/main.dart
new file mode 100644
index 0000000..d7ff5b1
--- /dev/null
+++ b/packages/morse_tap/example/lib/main.dart
@@ -0,0 +1,745 @@
+import 'package:flutter/material.dart';
+import 'package:morse_tap/morse_tap.dart';
+
+import 'haptic_config_modal.dart';
+
+void main() {
+ runApp(const MorseTapExampleApp());
+}
+
+class MorseTapExampleApp extends StatelessWidget {
+ const MorseTapExampleApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'Morse Tap Detector',
+ theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
+ home: const HomePage(),
+ );
+ }
+}
+
+class HomePage extends StatefulWidget {
+ const HomePage({super.key});
+
+ @override
+ State createState() => _HomePageState();
+}
+
+class _HomePageState extends State {
+ int _currentPage = 0;
+ final PageController _pageController = PageController();
+
+ final List _pages = [
+ const MorseTapDetectorExample(),
+ const MorseTextInputExample(),
+ const StringExtensionExample(),
+ ];
+
+ final List _pageTitles = [
+ 'Tap Detector',
+ 'Text Input',
+ 'String Extensions',
+ ];
+
+ @override
+ void dispose() {
+ _pageController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(_pageTitles[_currentPage]),
+ backgroundColor: Theme.of(context).colorScheme.inversePrimary,
+ ),
+ body: PageView(
+ controller: _pageController,
+ onPageChanged: (index) {
+ setState(() {
+ _currentPage = index;
+ });
+ },
+ children: _pages,
+ ),
+ bottomNavigationBar: BottomNavigationBar(
+ currentIndex: _currentPage,
+ onTap: (index) {
+ setState(() {
+ _currentPage = index;
+ });
+ _pageController.animateToPage(
+ index,
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeInOut,
+ );
+ },
+ items: const [
+ BottomNavigationBarItem(
+ icon: Icon(Icons.touch_app),
+ label: 'Tap Detector',
+ ),
+ BottomNavigationBarItem(
+ icon: Icon(Icons.text_fields),
+ label: 'Text Input',
+ ),
+ BottomNavigationBarItem(icon: Icon(Icons.code), label: 'Extensions'),
+ ],
+ ),
+ );
+ }
+}
+
+class MorseTapDetectorExample extends StatefulWidget {
+ const MorseTapDetectorExample({super.key});
+
+ @override
+ State createState() =>
+ _MorseTapDetectorExampleState();
+}
+
+class _MorseTapDetectorExampleState extends State {
+ String _message = 'Use gestures to input SOS (... --- ...)';
+ String _currentTarget = 'SOS';
+ Color _buttonColor = Colors.blue;
+ String _gestureHint = '';
+ String _currentSequence = '';
+ HapticConfig _hapticConfig = HapticConfig.defaultConfig;
+
+ final Map _targets = {
+ 'SOS': '... --- ...',
+ 'HELLO': '.... . .-.. .-.. ---',
+ 'OK': '--- -.-',
+ 'YES': '-.-- . ...',
+ 'NO': '-. ---',
+ };
+
+ void _onCorrectSequence() {
+ setState(() {
+ _message = '✅ Perfect! You tapped $_currentTarget correctly!';
+ _buttonColor = Colors.green;
+ _gestureHint = '';
+ });
+
+ Future.delayed(const Duration(seconds: 2), () {
+ if (mounted) {
+ setState(() {
+ _message =
+ 'Use gestures to input $_currentTarget (${_targets[_currentTarget]})';
+ _buttonColor = Colors.blue;
+ });
+ }
+ });
+ }
+
+ void _onIncorrectSequence() {
+ setState(() {
+ _message = '❌ Wrong sequence. Try again!';
+ _buttonColor = Colors.red;
+ _gestureHint = '';
+ });
+
+ Future.delayed(const Duration(seconds: 1), () {
+ if (mounted) {
+ setState(() {
+ _message =
+ 'Use gestures to input $_currentTarget (${_targets[_currentTarget]})';
+ _buttonColor = Colors.blue;
+ });
+ }
+ });
+ }
+
+ void _onTimeout() {
+ setState(() {
+ _message = '⏰ Sequence timed out. Try again!';
+ _buttonColor = Colors.orange;
+ _gestureHint = '';
+ });
+
+ Future.delayed(const Duration(seconds: 1), () {
+ if (mounted) {
+ setState(() {
+ _message =
+ 'Use gestures to input $_currentTarget (${_targets[_currentTarget]})';
+ _buttonColor = Colors.blue;
+ });
+ }
+ });
+ }
+
+ void _onDotAdded() {
+ setState(() {
+ _gestureHint = 'Added dot (•)';
+ });
+ }
+
+ void _onDashAdded() {
+ setState(() {
+ _gestureHint = 'Added dash (—)';
+ });
+ }
+
+ void _onSpaceAdded() {
+ setState(() {
+ _gestureHint = 'Added space';
+ });
+ }
+
+ void _showHapticConfig() async {
+ final newConfig = await HapticConfigModal.show(
+ context,
+ initialConfig: _hapticConfig,
+ );
+
+ if (newConfig != null) {
+ setState(() {
+ _hapticConfig = newConfig;
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: ListView(
+ children: [
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Morse Tap Detector',
+ style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ const Text(
+ 'Tap the button below using Morse code patterns. Short taps are dots (•), long taps are dashes (—).',
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'Target: $_currentTarget',
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ Text(
+ 'Morse: ${_targets[_currentTarget]}',
+ style: const TextStyle(
+ fontSize: 14,
+ fontFamily: 'monospace',
+ ),
+ ),
+ if (_currentSequence.isNotEmpty) ...[
+ const SizedBox(height: 8),
+ Text(
+ 'Current: $_currentSequence',
+ style: TextStyle(
+ fontSize: 14,
+ fontFamily: 'monospace',
+ color:
+ _targets[_currentTarget]!.startsWith(
+ _currentSequence,
+ )
+ ? Colors.green
+ : Colors.orange,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 16),
+
+ // Target selection
+ SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: _targets.keys.map((target) {
+ final isSelected = target == _currentTarget;
+ return Padding(
+ padding: const EdgeInsets.only(right: 8.0),
+ child: FilterChip(
+ label: Text(target),
+ selected: isSelected,
+ onSelected: (selected) {
+ if (selected) {
+ setState(() {
+ _currentTarget = target;
+ _message =
+ 'Use gestures to input $target (${_targets[target]})';
+ _buttonColor = Colors.blue;
+ _gestureHint = '';
+ _currentSequence = '';
+ });
+ }
+ },
+ ),
+ );
+ }).toList(),
+ ),
+ ),
+
+ const SizedBox(height: 16),
+
+ // Haptic configuration button
+ Row(
+ children: [
+ Expanded(
+ child: OutlinedButton.icon(
+ onPressed: _showHapticConfig,
+ icon: const Icon(Icons.vibration),
+ label: Text(
+ 'Haptic Settings ${_hapticConfig.enabled ? "(Enabled)" : "(Disabled)"}',
+ ),
+ ),
+ ),
+ ],
+ ),
+
+ const SizedBox(height: 16),
+
+ // Morse tap detector
+ Expanded(
+ child: MorseTapDetector(
+ expectedMorseCode: _targets[_currentTarget]!,
+ hapticConfig: _hapticConfig,
+ onCorrectSequence: _onCorrectSequence,
+ onIncorrectSequence: _onIncorrectSequence,
+ onInputTimeout: _onTimeout,
+ onSequenceChange: (sequence) {
+ setState(() {
+ _currentSequence = sequence;
+ });
+ },
+ onDotAdded: _onDotAdded,
+ onDashAdded: _onDashAdded,
+ onSpaceAdded: _onSpaceAdded,
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 300),
+ decoration: BoxDecoration(
+ color: _buttonColor,
+ borderRadius: BorderRadius.circular(12),
+ boxShadow: [
+ BoxShadow(
+ color: _buttonColor.withValues(alpha: 0.3),
+ blurRadius: 8,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ child: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(Icons.touch_app, size: 40, color: Colors.white),
+ const SizedBox(height: 8),
+ const Text(
+ 'TAP HERE',
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 20,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 6),
+ const Text(
+ '1 tap = • | 2 taps = — | Hold = space',
+ style: TextStyle(color: Colors.white70, fontSize: 12),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 12),
+
+ // Status message
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Colors.grey[100],
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ _message,
+ textAlign: TextAlign.center,
+ style: const TextStyle(fontSize: 15),
+ ),
+ if (_gestureHint.isNotEmpty) ...[
+ const SizedBox(height: 6),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10,
+ vertical: 3,
+ ),
+ decoration: BoxDecoration(
+ color: Colors.blue[50],
+ border: Border.all(color: Colors.blue[200]!),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(
+ _gestureHint,
+ style: TextStyle(
+ fontSize: 11,
+ color: Colors.blue[700],
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class MorseTextInputExample extends StatefulWidget {
+ const MorseTextInputExample({super.key});
+
+ @override
+ State createState() => _MorseTextInputExampleState();
+}
+
+class _MorseTextInputExampleState extends State {
+ final TextEditingController _textController = TextEditingController();
+ final TextEditingController _morseController = TextEditingController();
+ bool _autoConvert = true;
+
+ @override
+ void dispose() {
+ _textController.dispose();
+ _morseController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: ListView(
+ children: [
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Morse Text Input',
+ style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ const Text(
+ 'Tap in the input area to create Morse code. The widget converts your taps to text in real-time.',
+ ),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ Checkbox(
+ value: _autoConvert,
+ onChanged: (value) {
+ setState(() {
+ _autoConvert = value ?? true;
+ });
+ },
+ ),
+ const Text('Auto-convert to text'),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 16),
+
+ // Text input mode
+ Expanded(
+ child: MorseTextInput(
+ controller: _autoConvert ? _textController : _morseController,
+ autoConvertToText: _autoConvert,
+ showMorsePreview: true,
+ onTextChanged: (text) {
+ // Optional: handle text changes
+ },
+ decoration: InputDecoration(
+ labelText: _autoConvert ? 'Converted Text' : 'Morse Code',
+ helperText: _autoConvert
+ ? 'Text appears here as you tap'
+ : 'Raw Morse code appears here',
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 16),
+
+ // Instructions
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Instructions:',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ const Text('• Single tap = dot (•)'),
+ const Text('• Double tap = dash (—)'),
+ const Text('• Long press = space between letters'),
+ const Text('• Auto-completion after 1.2 seconds'),
+ const SizedBox(height: 12),
+ const Text(
+ 'Try tapping "HELLO" or "SOS"!',
+ style: TextStyle(
+ fontWeight: FontWeight.w500,
+ fontStyle: FontStyle.italic,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class StringExtensionExample extends StatefulWidget {
+ const StringExtensionExample({super.key});
+
+ @override
+ State createState() => _StringExtensionExampleState();
+}
+
+class _StringExtensionExampleState extends State {
+ final TextEditingController _inputController = TextEditingController(
+ text: 'HELLO WORLD',
+ );
+ String _morseOutput = '';
+ String _backToText = '';
+
+ @override
+ void initState() {
+ super.initState();
+ _updateOutputs();
+ _inputController.addListener(_updateOutputs);
+ }
+
+ @override
+ void dispose() {
+ _inputController.dispose();
+ super.dispose();
+ }
+
+ void _updateOutputs() {
+ final input = _inputController.text;
+ setState(() {
+ _morseOutput = input.toMorseCode();
+ _backToText = _morseOutput.fromMorseCode();
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.all(8.0),
+ child: ListView(
+ children: [
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(8.0),
+ child: const Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Demonstrates the extension methods available on String for Morse code conversion.',
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 16),
+
+ // Input field
+ TextField(
+ controller: _inputController,
+ decoration: const InputDecoration(
+ labelText: 'Input Text',
+ border: OutlineInputBorder(),
+ helperText: 'Type any text to see the Morse code conversion',
+ ),
+ textCapitalization: TextCapitalization.characters,
+ ),
+
+ const SizedBox(height: 16),
+
+ // Outputs
+ Expanded(
+ child: Column(
+ children: [
+ // Morse code output
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Icon(Icons.code, size: 16),
+ const SizedBox(width: 8),
+ const Text(
+ '.toMorseCode()',
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Colors.grey[100],
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: SelectableText(
+ _morseOutput.isEmpty
+ ? 'Enter text above...'
+ : _morseOutput,
+ style: const TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 16,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 12),
+
+ // Back to text output
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Icon(Icons.text_fields, size: 16),
+ const SizedBox(width: 8),
+ const Text(
+ '.fromMorseCode()',
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Colors.grey[100],
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: SelectableText(
+ _backToText.isEmpty
+ ? 'Converted text appears here...'
+ : _backToText,
+ style: const TextStyle(fontSize: 16),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 12),
+
+ // Validation indicators
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Validation Methods:',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ Row(
+ children: [
+ Icon(
+ _inputController.text.isValidMorseInput()
+ ? Icons.check_circle
+ : Icons.cancel,
+ color: _inputController.text.isValidMorseInput()
+ ? Colors.green
+ : Colors.red,
+ size: 16,
+ ),
+ const SizedBox(width: 8),
+ const Text('.isValidMorseInput()'),
+ ],
+ ),
+ const SizedBox(height: 4),
+ Row(
+ children: [
+ Icon(
+ _morseOutput.isValidMorseSequence()
+ ? Icons.check_circle
+ : Icons.cancel,
+ color: _morseOutput.isValidMorseSequence()
+ ? Colors.green
+ : Colors.red,
+ size: 16,
+ ),
+ const SizedBox(width: 8),
+ const Text('.isValidMorseSequence()'),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/packages/morse_tap/example/main.dart b/packages/morse_tap/example/main.dart
new file mode 100644
index 0000000..0a38ac5
--- /dev/null
+++ b/packages/morse_tap/example/main.dart
@@ -0,0 +1,749 @@
+import 'package:flutter/material.dart';
+import 'package:morse_tap/morse_tap.dart';
+import 'lib/haptic_config_modal.dart';
+
+void main() {
+ runApp(const MorseTapExampleApp());
+}
+
+class MorseTapExampleApp extends StatelessWidget {
+ const MorseTapExampleApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'Morse Tap Example',
+ theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
+ home: const HomePage(),
+ );
+ }
+}
+
+class HomePage extends StatefulWidget {
+ const HomePage({super.key});
+
+ @override
+ State createState() => _HomePageState();
+}
+
+class _HomePageState extends State {
+ int _currentPage = 0;
+ final PageController _pageController = PageController();
+
+ final List _pages = [
+ const MorseTapDetectorExample(),
+ const MorseTextInputExample(),
+ const StringExtensionExample(),
+ ];
+
+ final List _pageTitles = [
+ 'Tap Detector',
+ 'Text Input',
+ 'String Extensions',
+ ];
+
+ @override
+ void dispose() {
+ _pageController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(_pageTitles[_currentPage]),
+ backgroundColor: Theme.of(context).colorScheme.inversePrimary,
+ ),
+ body: PageView(
+ controller: _pageController,
+ onPageChanged: (index) {
+ setState(() {
+ _currentPage = index;
+ });
+ },
+ children: _pages,
+ ),
+ bottomNavigationBar: BottomNavigationBar(
+ currentIndex: _currentPage,
+ onTap: (index) {
+ setState(() {
+ _currentPage = index;
+ });
+ _pageController.animateToPage(
+ index,
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeInOut,
+ );
+ },
+ items: const [
+ BottomNavigationBarItem(
+ icon: Icon(Icons.touch_app),
+ label: 'Tap Detector',
+ ),
+ BottomNavigationBarItem(
+ icon: Icon(Icons.text_fields),
+ label: 'Text Input',
+ ),
+ BottomNavigationBarItem(icon: Icon(Icons.code), label: 'Extensions'),
+ ],
+ ),
+ );
+ }
+}
+
+class MorseTapDetectorExample extends StatefulWidget {
+ const MorseTapDetectorExample({super.key});
+
+ @override
+ State createState() =>
+ _MorseTapDetectorExampleState();
+}
+
+class _MorseTapDetectorExampleState extends State {
+ String _message = 'Use gestures to input SOS (... --- ...)';
+ String _currentTarget = 'SOS';
+ Color _buttonColor = Colors.blue;
+ String _gestureHint = '';
+ String _currentSequence = '';
+ HapticConfig _hapticConfig = HapticConfig.defaultConfig;
+
+ final Map _targets = {
+ 'SOS': '... --- ...',
+ 'HELLO': '.... . .-.. .-.. ---',
+ 'OK': '--- -.-',
+ 'YES': '-.-- . ...',
+ 'NO': '-. ---',
+ };
+
+ void _onCorrectSequence() {
+ setState(() {
+ _message = '✅ Perfect! You tapped $_currentTarget correctly!';
+ _buttonColor = Colors.green;
+ _gestureHint = '';
+ });
+
+ Future.delayed(const Duration(seconds: 2), () {
+ if (mounted) {
+ setState(() {
+ _message =
+ 'Use gestures to input $_currentTarget (${_targets[_currentTarget]})';
+ _buttonColor = Colors.blue;
+ });
+ }
+ });
+ }
+
+ void _onIncorrectSequence() {
+ setState(() {
+ _message = '❌ Wrong sequence. Try again!';
+ _buttonColor = Colors.red;
+ _gestureHint = '';
+ });
+
+ Future.delayed(const Duration(seconds: 1), () {
+ if (mounted) {
+ setState(() {
+ _message =
+ 'Use gestures to input $_currentTarget (${_targets[_currentTarget]})';
+ _buttonColor = Colors.blue;
+ });
+ }
+ });
+ }
+
+ void _onTimeout() {
+ setState(() {
+ _message = '⏰ Sequence timed out. Try again!';
+ _buttonColor = Colors.orange;
+ _gestureHint = '';
+ });
+
+ Future.delayed(const Duration(seconds: 1), () {
+ if (mounted) {
+ setState(() {
+ _message =
+ 'Use gestures to input $_currentTarget (${_targets[_currentTarget]})';
+ _buttonColor = Colors.blue;
+ });
+ }
+ });
+ }
+
+ void _onDotAdded() {
+ setState(() {
+ _gestureHint = 'Added dot (•)';
+ });
+ }
+
+ void _onDashAdded() {
+ setState(() {
+ _gestureHint = 'Added dash (—)';
+ });
+ }
+
+ void _onSpaceAdded() {
+ setState(() {
+ _gestureHint = 'Added space';
+ });
+ }
+
+ void _showHapticConfig() async {
+ final newConfig = await HapticConfigModal.show(
+ context,
+ initialConfig: _hapticConfig,
+ );
+
+ if (newConfig != null) {
+ setState(() {
+ _hapticConfig = newConfig;
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Morse Tap Detector',
+ style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ const Text(
+ 'Tap the button below using Morse code patterns. Short taps are dots (•), long taps are dashes (—).',
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'Target: $_currentTarget',
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ Text(
+ 'Morse: ${_targets[_currentTarget]}',
+ style: const TextStyle(
+ fontSize: 14,
+ fontFamily: 'monospace',
+ ),
+ ),
+ if (_currentSequence.isNotEmpty) ...[
+ const SizedBox(height: 8),
+ Text(
+ 'Current: $_currentSequence',
+ style: TextStyle(
+ fontSize: 14,
+ fontFamily: 'monospace',
+ color:
+ _targets[_currentTarget]!.startsWith(
+ _currentSequence,
+ )
+ ? Colors.green
+ : Colors.orange,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 16),
+
+ // Target selection
+ SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: _targets.keys.map((target) {
+ final isSelected = target == _currentTarget;
+ return Padding(
+ padding: const EdgeInsets.only(right: 8.0),
+ child: FilterChip(
+ label: Text(target),
+ selected: isSelected,
+ onSelected: (selected) {
+ if (selected) {
+ setState(() {
+ _currentTarget = target;
+ _message =
+ 'Use gestures to input $target (${_targets[target]})';
+ _buttonColor = Colors.blue;
+ _gestureHint = '';
+ _currentSequence = '';
+ });
+ }
+ },
+ ),
+ );
+ }).toList(),
+ ),
+ ),
+
+ const SizedBox(height: 16),
+
+ // Haptic configuration button
+ Row(
+ children: [
+ Expanded(
+ child: OutlinedButton.icon(
+ onPressed: _showHapticConfig,
+ icon: const Icon(Icons.vibration),
+ label: Text(
+ 'Haptic Settings ${_hapticConfig.enabled ? "(Enabled)" : "(Disabled)"}',
+ ),
+ ),
+ ),
+ ],
+ ),
+
+ const SizedBox(height: 24),
+
+ // Morse tap detector
+ Expanded(
+ child: MorseTapDetector(
+ expectedMorseCode: _targets[_currentTarget]!,
+ hapticConfig: _hapticConfig,
+ onCorrectSequence: _onCorrectSequence,
+ onIncorrectSequence: _onIncorrectSequence,
+ onInputTimeout: _onTimeout,
+ onSequenceChange: (sequence) {
+ setState(() {
+ _currentSequence = sequence;
+ });
+ },
+ onDotAdded: _onDotAdded,
+ onDashAdded: _onDashAdded,
+ onSpaceAdded: _onSpaceAdded,
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 300),
+ decoration: BoxDecoration(
+ color: _buttonColor,
+ borderRadius: BorderRadius.circular(12),
+ boxShadow: [
+ BoxShadow(
+ color: _buttonColor.withValues(alpha: 0.3),
+ blurRadius: 8,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ child: const Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.touch_app, size: 48, color: Colors.white),
+ SizedBox(height: 12),
+ Text(
+ 'TAP HERE',
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 24,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ SizedBox(height: 8),
+ Text(
+ '1 tap = • | 2 taps = — | Hold = space',
+ style: TextStyle(color: Colors.white70, fontSize: 14),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 16),
+
+ // Status message
+ Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: Colors.grey[100],
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Column(
+ children: [
+ Text(
+ _message,
+ textAlign: TextAlign.center,
+ style: const TextStyle(fontSize: 16),
+ ),
+ if (_gestureHint.isNotEmpty) ...[
+ const SizedBox(height: 8),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 4,
+ ),
+ decoration: BoxDecoration(
+ color: Colors.blue[50],
+ border: Border.all(color: Colors.blue[200]!),
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Text(
+ _gestureHint,
+ style: TextStyle(
+ fontSize: 12,
+ color: Colors.blue[700],
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class MorseTextInputExample extends StatefulWidget {
+ const MorseTextInputExample({super.key});
+
+ @override
+ State createState() => _MorseTextInputExampleState();
+}
+
+class _MorseTextInputExampleState extends State {
+ final TextEditingController _textController = TextEditingController();
+ final TextEditingController _morseController = TextEditingController();
+ bool _autoConvert = true;
+
+ @override
+ void dispose() {
+ _textController.dispose();
+ _morseController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Morse Text Input',
+ style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ const Text(
+ 'Tap in the input area to create Morse code. The widget converts your taps to text in real-time.',
+ ),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ Checkbox(
+ value: _autoConvert,
+ onChanged: (value) {
+ setState(() {
+ _autoConvert = value ?? true;
+ });
+ },
+ ),
+ const Text('Auto-convert to text'),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 16),
+
+ // Text input mode
+ Expanded(
+ child: MorseTextInput(
+ controller: _autoConvert ? _textController : _morseController,
+ autoConvertToText: _autoConvert,
+ showMorsePreview: true,
+ onTextChanged: (text) {
+ // Optional: handle text changes
+ },
+ decoration: InputDecoration(
+ labelText: _autoConvert ? 'Converted Text' : 'Morse Code',
+ helperText: _autoConvert
+ ? 'Text appears here as you tap'
+ : 'Raw Morse code appears here',
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 16),
+
+ // Instructions
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Instructions:',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ const Text('• Single tap = dot (•)'),
+ const Text('• Double tap = dash (—)'),
+ const Text('• Long press = space between letters'),
+ const Text('• Auto-completion after 1.2 seconds'),
+ const SizedBox(height: 12),
+ const Text(
+ 'Try tapping "HELLO" or "SOS"!',
+ style: TextStyle(
+ fontWeight: FontWeight.w500,
+ fontStyle: FontStyle.italic,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class StringExtensionExample extends StatefulWidget {
+ const StringExtensionExample({super.key});
+
+ @override
+ State createState() => _StringExtensionExampleState();
+}
+
+class _StringExtensionExampleState extends State {
+ final TextEditingController _inputController = TextEditingController(
+ text: 'HELLO WORLD',
+ );
+ String _morseOutput = '';
+ String _backToText = '';
+
+ @override
+ void initState() {
+ super.initState();
+ _updateOutputs();
+ _inputController.addListener(_updateOutputs);
+ }
+
+ @override
+ void dispose() {
+ _inputController.dispose();
+ super.dispose();
+ }
+
+ void _updateOutputs() {
+ final input = _inputController.text;
+ setState(() {
+ _morseOutput = input.toMorseCode();
+ _backToText = _morseOutput.fromMorseCode();
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: const Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'String Extensions',
+ style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
+ ),
+ SizedBox(height: 8),
+ Text(
+ 'Demonstrates the extension methods available on String for Morse code conversion.',
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 16),
+
+ // Input field
+ TextField(
+ controller: _inputController,
+ decoration: const InputDecoration(
+ labelText: 'Input Text',
+ border: OutlineInputBorder(),
+ helperText: 'Type any text to see the Morse code conversion',
+ ),
+ textCapitalization: TextCapitalization.characters,
+ ),
+
+ const SizedBox(height: 16),
+
+ // Outputs
+ Expanded(
+ child: Column(
+ children: [
+ // Morse code output
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Icon(Icons.code, size: 16),
+ const SizedBox(width: 8),
+ const Text(
+ '.toMorseCode()',
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Colors.grey[100],
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: SelectableText(
+ _morseOutput.isEmpty
+ ? 'Enter text above...'
+ : _morseOutput,
+ style: const TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 16,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 12),
+
+ // Back to text output
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Icon(Icons.text_fields, size: 16),
+ const SizedBox(width: 8),
+ const Text(
+ '.fromMorseCode()',
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Colors.grey[100],
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: SelectableText(
+ _backToText.isEmpty
+ ? 'Converted text appears here...'
+ : _backToText,
+ style: const TextStyle(fontSize: 16),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 12),
+
+ // Validation indicators
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Validation Methods:',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ const SizedBox(height: 8),
+ Row(
+ children: [
+ Icon(
+ _inputController.text.isValidMorseInput()
+ ? Icons.check_circle
+ : Icons.cancel,
+ color: _inputController.text.isValidMorseInput()
+ ? Colors.green
+ : Colors.red,
+ size: 16,
+ ),
+ const SizedBox(width: 8),
+ const Text('.isValidMorseInput()'),
+ ],
+ ),
+ const SizedBox(height: 4),
+ Row(
+ children: [
+ Icon(
+ _morseOutput.isValidMorseSequence()
+ ? Icons.check_circle
+ : Icons.cancel,
+ color: _morseOutput.isValidMorseSequence()
+ ? Colors.green
+ : Colors.red,
+ size: 16,
+ ),
+ const SizedBox(width: 8),
+ const Text('.isValidMorseSequence()'),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/packages/morse_tap/example/pubspec.yaml b/packages/morse_tap/example/pubspec.yaml
new file mode 100644
index 0000000..b321ebd
--- /dev/null
+++ b/packages/morse_tap/example/pubspec.yaml
@@ -0,0 +1,90 @@
+name: morse_tap_example
+description: "A new Flutter project."
+# The following line prevents the package from being accidentally published to
+# pub.dev using `flutter pub publish`. This is preferred for private packages.
+publish_to: 'none' # Remove this line if you wish to publish to pub.dev
+
+# The following defines the version and build number for your application.
+# A version number is three numbers separated by dots, like 1.2.43
+# followed by an optional build number separated by a +.
+# Both the version and the builder number may be overridden in flutter
+# build by specifying --build-name and --build-number, respectively.
+# In Android, build-name is used as versionName while build-number used as versionCode.
+# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
+# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
+# Read more about iOS versioning at
+# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
+# In Windows, build-name is used as the major, minor, and patch parts
+# of the product and file versions while build-number is used as the build suffix.
+version: 1.0.0+1
+
+environment:
+ sdk: ^3.8.1
+
+# Dependencies specify other packages that your package needs in order to work.
+# To automatically upgrade your package dependencies to the latest versions
+# consider running `flutter pub upgrade --major-versions`. Alternatively,
+# dependencies can be manually updated by changing the version numbers below to
+# the latest version available on pub.dev. To see which dependencies have newer
+# versions available, run `flutter pub outdated`.
+dependencies:
+ flutter:
+ sdk: flutter
+
+ # The following adds the Cupertino Icons font to your application.
+ # Use with the CupertinoIcons class for iOS style icons.
+ cupertino_icons: ^1.0.8
+ morse_tap:
+ path: ../
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+
+ # The "flutter_lints" package below contains a set of recommended lints to
+ # encourage good coding practices. The lint set provided by the package is
+ # activated in the `analysis_options.yaml` file located at the root of your
+ # package. See that file for information about deactivating specific lint
+ # rules and activating additional ones.
+ flutter_lints: ^5.0.0
+
+# For information on the generic Dart part of this file, see the
+# following page: https://dart.dev/tools/pub/pubspec
+
+# The following section is specific to Flutter packages.
+flutter:
+
+ # The following line ensures that the Material Icons font is
+ # included with your application, so that you can use the icons in
+ # the material Icons class.
+ uses-material-design: true
+
+ # To add assets to your application, add an assets section, like this:
+ # assets:
+ # - images/a_dot_burr.jpeg
+ # - images/a_dot_ham.jpeg
+
+ # An image asset can refer to one or more resolution-specific "variants", see
+ # https://flutter.dev/to/resolution-aware-images
+
+ # For details regarding adding assets from package dependencies, see
+ # https://flutter.dev/to/asset-from-package
+
+ # To add custom fonts to your application, add a fonts section here,
+ # in this "flutter" section. Each entry in this list should have a
+ # "family" key with the font family name, and a "fonts" key with a
+ # list giving the asset and other descriptors for the font. For
+ # example:
+ # fonts:
+ # - family: Schyler
+ # fonts:
+ # - asset: fonts/Schyler-Regular.ttf
+ # - asset: fonts/Schyler-Italic.ttf
+ # style: italic
+ # - family: Trajan Pro
+ # fonts:
+ # - asset: fonts/TrajanPro.ttf
+ # - asset: fonts/TrajanPro_Bold.ttf
+ # weight: 700
+ #
+ # For details regarding fonts from package dependencies,
+ # see https://flutter.dev/to/font-from-package
diff --git a/packages/morse_tap/lib/morse_tap.dart b/packages/morse_tap/lib/morse_tap.dart
new file mode 100644
index 0000000..930d48b
--- /dev/null
+++ b/packages/morse_tap/lib/morse_tap.dart
@@ -0,0 +1,13 @@
+/// A Flutter package that provides Morse code input functionality through tap-based widgets.
+///
+/// This library includes widgets for detecting Morse code tap patterns, converting
+/// tap input to text, and utility extensions for working with Morse code.
+library;
+
+export 'src/models/haptic_config.dart';
+export 'src/models/haptic_feedback_type.dart';
+export 'src/morse_algorithm.dart';
+export 'src/morse_extensions.dart';
+export 'src/utils/haptic_utils.dart';
+export 'src/widgets/morse_tap_detector.dart';
+export 'src/widgets/morse_text_input.dart';
diff --git a/packages/morse_tap/lib/src/models/haptic_config.dart b/packages/morse_tap/lib/src/models/haptic_config.dart
new file mode 100644
index 0000000..6564ef7
--- /dev/null
+++ b/packages/morse_tap/lib/src/models/haptic_config.dart
@@ -0,0 +1,136 @@
+import 'haptic_feedback_type.dart';
+
+/// Configuration class for haptic feedback settings in Morse code widgets.
+///
+/// This class defines the haptic feedback intensity for different Morse code
+/// gestures and events, allowing users to customize their tactile experience.
+class HapticConfig {
+ /// Creates a haptic configuration.
+ const HapticConfig({
+ this.enabled = false,
+ this.dotIntensity = HapticFeedbackType.lightImpact,
+ this.dashIntensity = HapticFeedbackType.mediumImpact,
+ this.spaceIntensity = HapticFeedbackType.heavyImpact,
+ this.correctSequenceIntensity = HapticFeedbackType.mediumImpact,
+ this.incorrectSequenceIntensity = HapticFeedbackType.heavyImpact,
+ this.timeoutIntensity = HapticFeedbackType.lightImpact,
+ });
+
+ /// Whether haptic feedback is enabled globally
+ final bool enabled;
+
+ /// Haptic intensity for dots (single taps)
+ final HapticFeedbackType dotIntensity;
+
+ /// Haptic intensity for dashes (double taps)
+ final HapticFeedbackType dashIntensity;
+
+ /// Haptic intensity for spaces (long press)
+ final HapticFeedbackType spaceIntensity;
+
+ /// Haptic intensity for correct sequence completion
+ final HapticFeedbackType correctSequenceIntensity;
+
+ /// Haptic intensity for incorrect sequences
+ final HapticFeedbackType incorrectSequenceIntensity;
+
+ /// Haptic intensity for input timeout
+ final HapticFeedbackType timeoutIntensity;
+
+ /// Creates a copy of this config with the given fields replaced with new values.
+ HapticConfig copyWith({
+ bool? enabled,
+ HapticFeedbackType? dotIntensity,
+ HapticFeedbackType? dashIntensity,
+ HapticFeedbackType? spaceIntensity,
+ HapticFeedbackType? correctSequenceIntensity,
+ HapticFeedbackType? incorrectSequenceIntensity,
+ HapticFeedbackType? timeoutIntensity,
+ }) {
+ return HapticConfig(
+ enabled: enabled ?? this.enabled,
+ dotIntensity: dotIntensity ?? this.dotIntensity,
+ dashIntensity: dashIntensity ?? this.dashIntensity,
+ spaceIntensity: spaceIntensity ?? this.spaceIntensity,
+ correctSequenceIntensity:
+ correctSequenceIntensity ?? this.correctSequenceIntensity,
+ incorrectSequenceIntensity:
+ incorrectSequenceIntensity ?? this.incorrectSequenceIntensity,
+ timeoutIntensity: timeoutIntensity ?? this.timeoutIntensity,
+ );
+ }
+
+ /// Default haptic configuration with moderate settings
+ static const HapticConfig defaultConfig = HapticConfig(
+ enabled: true,
+ dotIntensity: HapticFeedbackType.lightImpact,
+ dashIntensity: HapticFeedbackType.lightImpact,
+ spaceIntensity: HapticFeedbackType.lightImpact,
+ correctSequenceIntensity: HapticFeedbackType.mediumImpact,
+ incorrectSequenceIntensity: HapticFeedbackType.mediumImpact,
+ timeoutIntensity: HapticFeedbackType.mediumImpact,
+ );
+
+ /// Disabled haptic configuration
+ static const HapticConfig disabled = HapticConfig(enabled: false);
+
+ /// Light haptic configuration with subtle feedback
+ static const HapticConfig light = HapticConfig(
+ enabled: true,
+ dotIntensity: HapticFeedbackType.selectionClick,
+ dashIntensity: HapticFeedbackType.lightImpact,
+ spaceIntensity: HapticFeedbackType.mediumImpact,
+ correctSequenceIntensity: HapticFeedbackType.lightImpact,
+ incorrectSequenceIntensity: HapticFeedbackType.mediumImpact,
+ timeoutIntensity: HapticFeedbackType.selectionClick,
+ );
+
+ /// Strong haptic configuration with intense feedback
+ static const HapticConfig strong = HapticConfig(
+ enabled: true,
+ dotIntensity: HapticFeedbackType.mediumImpact,
+ dashIntensity: HapticFeedbackType.heavyImpact,
+ spaceIntensity: HapticFeedbackType.heavyImpact,
+ correctSequenceIntensity: HapticFeedbackType.heavyImpact,
+ incorrectSequenceIntensity: HapticFeedbackType.heavyImpact,
+ timeoutIntensity: HapticFeedbackType.mediumImpact,
+ );
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ return other is HapticConfig &&
+ other.enabled == enabled &&
+ other.dotIntensity == dotIntensity &&
+ other.dashIntensity == dashIntensity &&
+ other.spaceIntensity == spaceIntensity &&
+ other.correctSequenceIntensity == correctSequenceIntensity &&
+ other.incorrectSequenceIntensity == incorrectSequenceIntensity &&
+ other.timeoutIntensity == timeoutIntensity;
+ }
+
+ @override
+ int get hashCode {
+ return Object.hash(
+ enabled,
+ dotIntensity,
+ dashIntensity,
+ spaceIntensity,
+ correctSequenceIntensity,
+ incorrectSequenceIntensity,
+ timeoutIntensity,
+ );
+ }
+
+ @override
+ String toString() {
+ return 'HapticConfig('
+ 'enabled: $enabled, '
+ 'dotIntensity: $dotIntensity, '
+ 'dashIntensity: $dashIntensity, '
+ 'spaceIntensity: $spaceIntensity, '
+ 'correctSequenceIntensity: $correctSequenceIntensity, '
+ 'incorrectSequenceIntensity: $incorrectSequenceIntensity, '
+ 'timeoutIntensity: $timeoutIntensity)';
+ }
+}
diff --git a/packages/morse_tap/lib/src/models/haptic_feedback_type.dart b/packages/morse_tap/lib/src/models/haptic_feedback_type.dart
new file mode 100644
index 0000000..036ebe0
--- /dev/null
+++ b/packages/morse_tap/lib/src/models/haptic_feedback_type.dart
@@ -0,0 +1,18 @@
+/// Custom enum for haptic feedback types to provide a unified interface
+/// for different haptic feedback intensities available in Flutter.
+enum HapticFeedbackType {
+ /// Light haptic feedback - subtle, gentle touch sensation
+ lightImpact,
+
+ /// Medium haptic feedback - moderate, noticeable touch sensation
+ mediumImpact,
+
+ /// Heavy haptic feedback - strong, pronounced touch sensation
+ heavyImpact,
+
+ /// Selection click feedback - quick, precise feedback for selections
+ selectionClick,
+
+ /// Vibration feedback - standard system vibration pattern
+ vibrate,
+}
diff --git a/packages/morse_tap/lib/src/morse_algorithm.dart b/packages/morse_tap/lib/src/morse_algorithm.dart
new file mode 100644
index 0000000..654ca37
--- /dev/null
+++ b/packages/morse_tap/lib/src/morse_algorithm.dart
@@ -0,0 +1,194 @@
+/// Core algorithm for Morse code conversion and validation.
+class MorseCodec {
+ /// Character mappings from text to Morse code
+ static const Map _textToMorse = {
+ 'A': '.-',
+ 'B': '-...',
+ 'C': '-.-.',
+ 'D': '-..',
+ 'E': '.',
+ 'F': '..-.',
+ 'G': '--.',
+ 'H': '....',
+ 'I': '..',
+ 'J': '.---',
+ 'K': '-.-',
+ 'L': '.-..',
+ 'M': '--',
+ 'N': '-.',
+ 'O': '---',
+ 'P': '.--.',
+ 'Q': '--.-',
+ 'R': '.-.',
+ 'S': '...',
+ 'T': '-',
+ 'U': '..-',
+ 'V': '...-',
+ 'W': '.--',
+ 'X': '-..-',
+ 'Y': '-.--',
+ 'Z': '--..',
+ '0': '-----',
+ '1': '.----',
+ '2': '..---',
+ '3': '...--',
+ '4': '....-',
+ '5': '.....',
+ '6': '-....',
+ '7': '--...',
+ '8': '---..',
+ '9': '----.',
+ '.': '.-.-.-',
+ ',': '--..--',
+ '?': '..--..',
+ "'": '.----.',
+ '!': '-.-.--',
+ '/': '-..-.',
+ '(': '-.--.',
+ ')': '-.--.-',
+ '&': '.-...',
+ ':': '---...',
+ ';': '-.-.-.',
+ '=': '-...-',
+ '+': '.-.-.',
+ '-': '-....-',
+ '_': '..--.-',
+ '"': '.-..-.',
+ '\$': '...-..-',
+ '@': '.--.-.',
+ };
+
+ /// Reverse mapping for Morse code to text conversion
+ static final Map _morseToText = {
+ for (final entry in _textToMorse.entries) entry.value: entry.key,
+ };
+
+ /// Converts text to Morse code representation
+ ///
+ /// [text] The input text to convert
+ /// Returns Morse code string with spaces between letters and " / " between words
+ static String textToMorse(String text) {
+ if (text.isEmpty) return '';
+
+ final words = text.toUpperCase().split(' ');
+ final morseWords = [];
+
+ for (final word in words) {
+ if (word.isEmpty) continue;
+
+ final morseLetters = [];
+ for (int i = 0; i < word.length; i++) {
+ final char = word[i];
+ final morse = _textToMorse[char];
+ if (morse != null) {
+ morseLetters.add(morse);
+ }
+ }
+
+ if (morseLetters.isNotEmpty) {
+ morseWords.add(morseLetters.join(' '));
+ }
+ }
+
+ return morseWords.join(' / ');
+ }
+
+ /// Converts Morse code to text
+ ///
+ /// [morse] The Morse code string to convert
+ /// Returns the decoded text
+ static String morseToText(String morse) {
+ if (morse.isEmpty) return '';
+
+ final words = morse.split(' / ');
+ final textWords = [];
+
+ for (final word in words) {
+ if (word.trim().isEmpty) continue;
+
+ final letters = word.trim().split(' ');
+ final textLetters = [];
+
+ for (final letter in letters) {
+ if (letter.isEmpty) continue;
+ final char = _morseToText[letter];
+ if (char != null) {
+ textLetters.add(char);
+ }
+ }
+
+ if (textLetters.isNotEmpty) {
+ textWords.add(textLetters.join());
+ }
+ }
+
+ return textWords.join(' ');
+ }
+
+ /// Validates if a Morse code sequence is valid
+ ///
+ /// [sequence] The Morse code sequence to validate
+ /// Returns true if all characters in the sequence are valid Morse codes
+ static bool isValidMorseSequence(String sequence) {
+ if (sequence.isEmpty) return true;
+
+ // Split by word separators first
+ final words = sequence.split(' / ');
+
+ for (final word in words) {
+ if (word.trim().isEmpty) continue;
+
+ // Split by letter separators
+ final letters = word.trim().split(' ');
+
+ for (final letter in letters) {
+ if (letter.isEmpty) continue;
+
+ // Check if this morse code exists in our mapping
+ if (!_morseToText.containsKey(letter)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /// Gets the relative duration for a Morse character
+ ///
+ /// [morseChar] Single Morse character (. or -)
+ /// Returns duration in milliseconds (dot = 100ms, dash = 300ms)
+ static Duration getCharacterDuration(String morseChar) {
+ switch (morseChar) {
+ case '.':
+ return const Duration(milliseconds: 100);
+ case '-':
+ return const Duration(milliseconds: 300);
+ default:
+ return const Duration(milliseconds: 0);
+ }
+ }
+
+ /// Splits a Morse code sequence into individual characters
+ ///
+ /// [sequence] The Morse code sequence to split
+ /// Returns list of individual Morse characters (dots and dashes)
+ static List splitMorseSequence(String sequence) {
+ final characters = [];
+
+ for (int i = 0; i < sequence.length; i++) {
+ final char = sequence[i];
+ if (char == '.' || char == '-') {
+ characters.add(char);
+ }
+ }
+
+ return characters;
+ }
+
+ /// Gets all supported characters
+ static Set get supportedCharacters => _textToMorse.keys.toSet();
+
+ /// Gets all Morse codes
+ static Set get supportedMorseCodes => _morseToText.keys.toSet();
+}
diff --git a/packages/morse_tap/lib/src/morse_extensions.dart b/packages/morse_tap/lib/src/morse_extensions.dart
new file mode 100644
index 0000000..7f0a6d2
--- /dev/null
+++ b/packages/morse_tap/lib/src/morse_extensions.dart
@@ -0,0 +1,86 @@
+import 'morse_algorithm.dart';
+
+/// Extension methods for converting strings to Morse code
+extension StringToMorse on String {
+ /// Converts this string to Morse code representation
+ ///
+ /// Returns a string with dots and dashes representing the Morse code.
+ /// Spaces separate letters, and " / " separates words.
+ ///
+ /// Example:
+ /// ```dart
+ /// "HELLO".toMorseCode(); // Returns: ".... . .-.. .-.. ---"
+ /// "SOS".toMorseCode(); // Returns: "... --- ..."
+ /// ```
+ String toMorseCode() {
+ return MorseCodec.textToMorse(this);
+ }
+
+ /// Converts this string to Morse code with timing indicators
+ ///
+ /// Returns a formatted string that includes timing information for
+ /// dots, dashes, and pauses between letters and words.
+ ///
+ /// Format:
+ /// - . = dot (100ms)
+ /// - - = dash (300ms)
+ /// - (space) = letter separator (200ms pause)
+ /// - / = word separator (700ms pause)
+ ///
+ /// Example:
+ /// ```dart
+ /// "HI".toMorseCodeWithTiming(); // Returns: ".... .. (dot=100ms, dash=300ms)"
+ /// ```
+ String toMorseCodeWithTiming() {
+ final morse = toMorseCode();
+ if (morse.isEmpty) return '';
+
+ return '$morse (dot=100ms, dash=300ms, letter_gap=200ms, word_gap=700ms)';
+ }
+
+ /// Validates if this string contains only valid Morse code input characters
+ ///
+ /// Returns true if the string contains only dots (.), dashes (-), spaces,
+ /// and forward slashes (/) which are valid Morse code characters.
+ ///
+ /// Example:
+ /// ```dart
+ /// "... --- ...".isValidMorseInput(); // Returns: true
+ /// "... abc ...".isValidMorseInput(); // Returns: false
+ /// ```
+ bool isValidMorseInput() {
+ if (isEmpty) return true;
+
+ // Check if string contains only valid Morse characters: ., -, space, /
+ final validPattern = RegExp(r'^[.\-\s/]*$');
+ return validPattern.hasMatch(this);
+ }
+
+ /// Converts Morse code string back to text
+ ///
+ /// If this string is a valid Morse code sequence, it will be converted
+ /// back to readable text. Invalid sequences will return an empty string.
+ ///
+ /// Example:
+ /// ```dart
+ /// "... --- ...".fromMorseCode(); // Returns: "SOS"
+ /// ".... . .-.. .-.. ---".fromMorseCode(); // Returns: "HELLO"
+ /// ```
+ String fromMorseCode() {
+ return MorseCodec.morseToText(this);
+ }
+
+ /// Checks if this string represents a valid Morse code sequence
+ ///
+ /// Returns true if all Morse codes in the string are valid and can be
+ /// converted back to text.
+ ///
+ /// Example:
+ /// ```dart
+ /// "... --- ...".isValidMorseSequence(); // Returns: true
+ /// "... xyz ...".isValidMorseSequence(); // Returns: false
+ /// ```
+ bool isValidMorseSequence() {
+ return MorseCodec.isValidMorseSequence(this);
+ }
+}
diff --git a/packages/morse_tap/lib/src/utils/haptic_utils.dart b/packages/morse_tap/lib/src/utils/haptic_utils.dart
new file mode 100644
index 0000000..2241126
--- /dev/null
+++ b/packages/morse_tap/lib/src/utils/haptic_utils.dart
@@ -0,0 +1,149 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import '../models/haptic_config.dart';
+import '../models/haptic_feedback_type.dart';
+import 'platform_utils_io.dart'
+ if (dart.library.html) 'platform_utils_web.dart';
+
+/// Utility class for managing haptic feedback in Morse code widgets.
+///
+/// Provides safe execution of haptic feedback with platform detection,
+/// error handling, and user-friendly descriptions for haptic types.
+class HapticUtils {
+ HapticUtils._();
+
+ /// Map of haptic feedback types to user-friendly display names
+ static final Map hapticTypeNames = {
+ HapticFeedbackType.lightImpact: 'Light',
+ HapticFeedbackType.mediumImpact: 'Medium',
+ HapticFeedbackType.heavyImpact: 'Heavy',
+ HapticFeedbackType.selectionClick: 'Selection',
+ HapticFeedbackType.vibrate: 'Vibrate',
+ };
+
+ /// Map of haptic feedback types to detailed descriptions
+ static final Map hapticTypeDescriptions = {
+ HapticFeedbackType.lightImpact: 'Subtle, gentle feedback',
+ HapticFeedbackType.mediumImpact: 'Moderate, noticeable feedback',
+ HapticFeedbackType.heavyImpact: 'Strong, pronounced feedback',
+ HapticFeedbackType.selectionClick: 'Quick selection feedback',
+ HapticFeedbackType.vibrate: 'Standard vibration pattern',
+ };
+
+ /// List of available haptic feedback types in order of intensity
+ static final List availableHapticTypes = [
+ HapticFeedbackType.selectionClick,
+ HapticFeedbackType.lightImpact,
+ HapticFeedbackType.mediumImpact,
+ HapticFeedbackType.heavyImpact,
+ HapticFeedbackType.vibrate,
+ ];
+
+ /// Checks if haptic feedback is supported on the current platform
+ static bool get isHapticSupported {
+ if (kIsWeb) return false;
+ return PlatformUtils.isHapticSupported;
+ }
+
+ /// Safely executes haptic feedback with error handling
+ ///
+ /// Returns true if haptic was executed successfully, false otherwise.
+ static Future triggerHaptic(HapticFeedbackType? type) async {
+ if (type == null || !isHapticSupported) {
+ return false;
+ }
+
+ try {
+ switch (type) {
+ case HapticFeedbackType.lightImpact:
+ await HapticFeedback.lightImpact();
+ case HapticFeedbackType.mediumImpact:
+ await HapticFeedback.mediumImpact();
+ case HapticFeedbackType.heavyImpact:
+ await HapticFeedback.heavyImpact();
+ case HapticFeedbackType.selectionClick:
+ await HapticFeedback.selectionClick();
+ case HapticFeedbackType.vibrate:
+ await HapticFeedback.vibrate();
+ }
+ return true;
+ } catch (e) {
+ // Silently handle haptic feedback errors
+ debugPrint('Haptic feedback error: $e');
+ return false;
+ }
+ }
+
+ /// Executes haptic feedback based on configuration
+ ///
+ /// Only triggers if haptic is enabled in the config and supported by platform.
+ static Future executeFromConfig(
+ HapticConfig? config,
+ HapticFeedbackType type,
+ ) async {
+ if (config == null || !config.enabled) {
+ return false;
+ }
+ return await triggerHaptic(type);
+ }
+
+ /// Gets the display name for a haptic feedback type
+ static String getHapticTypeName(HapticFeedbackType type) {
+ return hapticTypeNames[type] ?? 'Unknown';
+ }
+
+ /// Gets the description for a haptic feedback type
+ static String getHapticTypeDescription(HapticFeedbackType type) {
+ return hapticTypeDescriptions[type] ?? 'No description available';
+ }
+
+ /// Creates a dropdown item for a haptic feedback type
+ static DropdownMenuItem createDropdownItem(
+ HapticFeedbackType type,
+ ) {
+ return DropdownMenuItem(
+ value: type,
+ child: Text(getHapticTypeName(type)),
+ );
+ }
+
+ /// Preset configurations for quick selection
+ static final Map presetConfigs = {
+ 'Disabled': HapticConfig.disabled,
+ 'Light': HapticConfig.light,
+ 'Default': HapticConfig.defaultConfig,
+ 'Strong': HapticConfig.strong,
+ };
+
+ /// Gets the name of a preset configuration, if it matches
+ static String? getPresetName(HapticConfig config) {
+ for (final entry in presetConfigs.entries) {
+ if (entry.value == config) {
+ return entry.key;
+ }
+ }
+ return null;
+ }
+
+ /// Tests haptic feedback by triggering it immediately
+ ///
+ /// Used for preview functionality in configuration dialogs.
+ static Future testHaptic(HapticFeedbackType type) async {
+ if (isHapticSupported) {
+ await triggerHaptic(type);
+ // Add a small delay to prevent rapid-fire haptics
+ await Future.delayed(const Duration(milliseconds: 100));
+ }
+ }
+
+ /// Validates a haptic configuration
+ ///
+ /// Returns null if valid, or an error message if invalid.
+ static String? validateConfig(HapticConfig config) {
+ if (!isHapticSupported && config.enabled) {
+ return 'Haptic feedback is not supported on this platform';
+ }
+ return null;
+ }
+}
diff --git a/packages/morse_tap/lib/src/utils/platform_utils_io.dart b/packages/morse_tap/lib/src/utils/platform_utils_io.dart
new file mode 100644
index 0000000..b39d285
--- /dev/null
+++ b/packages/morse_tap/lib/src/utils/platform_utils_io.dart
@@ -0,0 +1,9 @@
+import 'dart:io';
+
+/// Platform utilities for native platforms (mobile and desktop)
+class PlatformUtils {
+ /// Checks if haptic feedback is supported on the current platform
+ /// Returns true for iOS and Android platforms
+ /// Desktop platforms (Linux, macOS, Windows) don't support haptic feedback
+ static bool get isHapticSupported => Platform.isIOS || Platform.isAndroid;
+}
diff --git a/packages/morse_tap/lib/src/utils/platform_utils_web.dart b/packages/morse_tap/lib/src/utils/platform_utils_web.dart
new file mode 100644
index 0000000..192b256
--- /dev/null
+++ b/packages/morse_tap/lib/src/utils/platform_utils_web.dart
@@ -0,0 +1,6 @@
+/// Platform utilities for web platform
+class PlatformUtils {
+ /// Checks if haptic feedback is supported on the current platform
+ /// Always returns false for web platform
+ static bool get isHapticSupported => false;
+}
diff --git a/packages/morse_tap/lib/src/widgets/morse_tap_detector.dart b/packages/morse_tap/lib/src/widgets/morse_tap_detector.dart
new file mode 100644
index 0000000..e19624d
--- /dev/null
+++ b/packages/morse_tap/lib/src/widgets/morse_tap_detector.dart
@@ -0,0 +1,219 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import '../models/haptic_config.dart';
+import '../utils/haptic_utils.dart';
+
+/// A widget that detects Morse code tap patterns and triggers callbacks
+/// when the correct sequence is tapped.
+///
+/// The widget uses discrete gestures:
+/// - Single tap (onTap) = dot (.)
+/// - Double tap (onDoubleTap) = dash (-)
+/// - Long press (onLongPress) = space between letters
+///
+/// It validates the tapped sequence against the expected Morse code pattern
+/// and only triggers [onCorrectSequence] when the correct sequence is completed.
+///
+/// The timeout resets after each input, giving users time to enter the next
+/// character. This allows for entering long sequences at a comfortable pace.
+class MorseTapDetector extends StatefulWidget {
+ /// Creates a Morse tap detector widget.
+ ///
+ /// [expectedMorseCode] The Morse code sequence that should be tapped
+ /// [onCorrectSequence] Callback triggered when correct sequence is completed
+ /// [child] The child widget to wrap
+ /// [inputTimeout] Timeout duration to wait for the next input character
+ const MorseTapDetector({
+ super.key,
+ required this.expectedMorseCode,
+ required this.onCorrectSequence,
+ required this.child,
+ this.inputTimeout = const Duration(seconds: 10),
+ this.hapticConfig,
+ this.onIncorrectSequence,
+ this.onInputTimeout,
+ this.onSequenceChange,
+ this.onDotAdded,
+ this.onDashAdded,
+ this.onSpaceAdded,
+ });
+
+ /// The expected Morse code sequence (e.g., "... --- ..." for SOS)
+ final String expectedMorseCode;
+
+ /// Callback triggered when the correct sequence is detected
+ final VoidCallback onCorrectSequence;
+
+ /// The child widget to detect taps on
+ final Widget child;
+
+ /// Timeout duration to wait for the next input character.
+ /// This resets after each valid input, allowing users to take their time
+ /// with long sequences as long as they keep entering characters.
+ final Duration inputTimeout;
+
+ /// Haptic feedback configuration
+ /// If null, no haptic feedback will be provided
+ final HapticConfig? hapticConfig;
+
+ /// Callback for when an incorrect sequence is detected
+ final VoidCallback? onIncorrectSequence;
+
+ /// Callback for when input times out (no input received within timeout duration)
+ final VoidCallback? onInputTimeout;
+
+ /// Callback when a dot is added
+ final VoidCallback? onDotAdded;
+
+ /// Callback when a dash is added
+ final VoidCallback? onDashAdded;
+
+ /// Callback when a space is added
+ final VoidCallback? onSpaceAdded;
+
+ /// Callback when the sequence changes
+ /// Provides the current sequence string, empty when incorrect or reset
+ final ValueChanged? onSequenceChange;
+
+ @override
+ State createState() => _MorseTapDetectorState();
+}
+
+class _MorseTapDetectorState extends State {
+ final List _currentSequence = [];
+ Timer? _timeoutTimer;
+
+ @override
+ void initState() {
+ super.initState();
+ }
+
+ @override
+ void dispose() {
+ _timeoutTimer?.cancel();
+ super.dispose();
+ }
+
+ void _startInputTimeout() {
+ _timeoutTimer?.cancel();
+ _timeoutTimer = Timer(widget.inputTimeout, () {
+ // Trigger timeout haptic feedback
+ if (widget.hapticConfig != null) {
+ HapticUtils.executeFromConfig(
+ widget.hapticConfig,
+ widget.hapticConfig!.timeoutIntensity,
+ );
+ }
+
+ _resetSequence();
+ widget.onInputTimeout?.call();
+ });
+ }
+
+ void _resetSequence() {
+ _currentSequence.clear();
+ _timeoutTimer?.cancel();
+ // Notify sequence change with empty string on reset
+ widget.onSequenceChange?.call('');
+ }
+
+ void _addMorseCharacter(String character) {
+ _currentSequence.add(character);
+ _startInputTimeout();
+
+ // Notify sequence change
+ final currentMorse = _currentSequence.join('');
+ widget.onSequenceChange?.call(currentMorse);
+
+ // Check if sequence matches expected pattern
+ _checkSequence();
+ }
+
+ void _onSingleTap() {
+ // Single tap = dot
+ _addMorseCharacter('.');
+ widget.onDotAdded?.call();
+
+ // Trigger haptic feedback
+ if (widget.hapticConfig != null) {
+ HapticUtils.executeFromConfig(
+ widget.hapticConfig,
+ widget.hapticConfig!.dotIntensity,
+ );
+ }
+ }
+
+ void _onDoubleTap() {
+ // Double tap = dash
+ _addMorseCharacter('-');
+ widget.onDashAdded?.call();
+
+ // Trigger haptic feedback
+ if (widget.hapticConfig != null) {
+ HapticUtils.executeFromConfig(
+ widget.hapticConfig,
+ widget.hapticConfig!.dashIntensity,
+ );
+ }
+ }
+
+ void _onLongPress() {
+ // Long press = space (letter separator)
+ _addMorseCharacter(' ');
+ widget.onSpaceAdded?.call();
+
+ // Trigger haptic feedback
+ if (widget.hapticConfig != null) {
+ HapticUtils.executeFromConfig(
+ widget.hapticConfig,
+ widget.hapticConfig!.spaceIntensity,
+ );
+ }
+ }
+
+ void _checkSequence() {
+ final currentMorse = _currentSequence.join('');
+ final expectedMorse = widget.expectedMorseCode;
+
+ if (currentMorse == expectedMorse) {
+ // Correct sequence detected!
+ widget.onCorrectSequence();
+
+ // Trigger success haptic feedback
+ if (widget.hapticConfig != null) {
+ HapticUtils.executeFromConfig(
+ widget.hapticConfig,
+ widget.hapticConfig!.correctSequenceIntensity,
+ );
+ }
+
+ _resetSequence();
+ } else if (currentMorse.length >= expectedMorse.length ||
+ !expectedMorse.startsWith(currentMorse)) {
+ // Sequence is wrong or too long
+ widget.onIncorrectSequence?.call();
+
+ // Trigger error haptic feedback
+ if (widget.hapticConfig != null) {
+ HapticUtils.executeFromConfig(
+ widget.hapticConfig,
+ widget.hapticConfig!.incorrectSequenceIntensity,
+ );
+ }
+
+ _resetSequence();
+ }
+ // Otherwise, continue waiting for more input
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: _onSingleTap,
+ onDoubleTap: _onDoubleTap,
+ onLongPress: _onLongPress,
+ child: widget.child,
+ );
+ }
+}
diff --git a/packages/morse_tap/lib/src/widgets/morse_text_input.dart b/packages/morse_tap/lib/src/widgets/morse_text_input.dart
new file mode 100644
index 0000000..63e6054
--- /dev/null
+++ b/packages/morse_tap/lib/src/widgets/morse_text_input.dart
@@ -0,0 +1,539 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import '../morse_algorithm.dart';
+
+/// A widget that converts tap input to Morse code and updates a text controller
+/// or calls a method in real-time.
+///
+/// This widget provides a complete Morse code input experience using gestures:
+/// - Single tap = dot (.)
+/// - Double tap = dash (-)
+/// - Long press = space between letters
+///
+/// It converts Morse patterns into readable text automatically or provides raw Morse code.
+class MorseTextInput extends StatefulWidget {
+ /// Creates a Morse text input widget.
+ ///
+ /// Either [controller] or [onTextChanged] must be provided.
+ ///
+ /// [controller] Text editing controller to update with converted text
+ /// [onTextChanged] Callback for text changes
+ /// [letterGap] Pause duration between letters for auto-completion
+ /// [wordGap] Pause duration between words for auto-completion
+ /// [showMorsePreview] Whether to show Morse code preview
+ /// [autoConvertToText] Whether to auto-convert Morse to readable text
+ /// [onClear] Callback when input is cleared
+ /// [decoration] Input decoration for the text field
+ const MorseTextInput({
+ super.key,
+ this.controller,
+ this.onTextChanged,
+ this.letterGap = const Duration(milliseconds: 1200),
+ this.wordGap = const Duration(seconds: 3),
+ this.showMorsePreview = true,
+ this.autoConvertToText = true,
+ this.onClear,
+ this.decoration,
+ this.tapAreaHeight = 120.0,
+ this.feedbackColor = Colors.blue,
+ }) : assert(
+ controller != null || onTextChanged != null,
+ 'Either controller or onTextChanged must be provided',
+ );
+
+ /// Text editing controller to update with converted text
+ final TextEditingController? controller;
+
+ /// Callback for text changes
+ final ValueChanged? onTextChanged;
+
+ /// Pause duration between letters for auto letter completion
+ final Duration letterGap;
+
+ /// Pause duration between words for auto word completion
+ final Duration wordGap;
+
+ /// Whether to show Morse code preview above the input
+ final bool showMorsePreview;
+
+ /// Whether to automatically convert Morse code to readable text
+ final bool autoConvertToText;
+
+ /// Callback when input is cleared
+ final VoidCallback? onClear;
+
+ /// Input decoration for the text field display
+ final InputDecoration? decoration;
+
+ /// Height of the tap detection area
+ final double tapAreaHeight;
+
+ /// Color for tap feedback
+ final Color feedbackColor;
+
+ @override
+ State createState() => _MorseTextInputState();
+}
+
+class _MorseTextInputState extends State
+ with TickerProviderStateMixin {
+ late final TextEditingController _internalController;
+ final List _currentLetter = [];
+ final List _morseWords = [];
+ final List _morseLetters = [];
+
+ Timer? _letterGapTimer;
+ Timer? _wordGapTimer;
+
+ late AnimationController _dotController;
+ late AnimationController _dashController;
+ late AnimationController _spaceController;
+
+ @override
+ void initState() {
+ super.initState();
+ _internalController = widget.controller ?? TextEditingController();
+
+ // Animation controllers for visual feedback
+ _dotController = AnimationController(
+ duration: const Duration(milliseconds: 200),
+ vsync: this,
+ );
+
+ _dashController = AnimationController(
+ duration: const Duration(milliseconds: 300),
+ vsync: this,
+ );
+
+ _spaceController = AnimationController(
+ duration: const Duration(milliseconds: 150),
+ vsync: this,
+ );
+ }
+
+ @override
+ void dispose() {
+ _letterGapTimer?.cancel();
+ _wordGapTimer?.cancel();
+ _dotController.dispose();
+ _dashController.dispose();
+ _spaceController.dispose();
+ if (widget.controller == null) {
+ _internalController.dispose();
+ }
+ super.dispose();
+ }
+
+ void _onSingleTap() {
+ // Single tap = dot
+ _currentLetter.add('.');
+ _dotController.forward().then((_) => _dotController.reverse());
+
+ // Cancel pending timers and start letter gap timer
+ _letterGapTimer?.cancel();
+ _wordGapTimer?.cancel();
+
+ _letterGapTimer = Timer(widget.letterGap, () {
+ _completeLetter();
+ });
+
+ setState(() {});
+ }
+
+ void _onDoubleTap() {
+ // Double tap = dash
+ _currentLetter.add('-');
+ _dashController.forward().then((_) => _dashController.reverse());
+
+ // Cancel pending timers and start letter gap timer
+ _letterGapTimer?.cancel();
+ _wordGapTimer?.cancel();
+
+ _letterGapTimer = Timer(widget.letterGap, () {
+ _completeLetter();
+ });
+
+ setState(() {});
+ }
+
+ void _onLongPress() {
+ // Long press = complete current letter and add space
+ _spaceController.forward().then((_) => _spaceController.reverse());
+
+ _letterGapTimer?.cancel();
+ _wordGapTimer?.cancel();
+
+ if (_currentLetter.isNotEmpty) {
+ _completeLetter();
+ }
+
+ // Force word completion after a short delay to allow letter to process
+ Timer(const Duration(milliseconds: 100), () {
+ _completeWord();
+ });
+ }
+
+ void _completeLetter() {
+ if (_currentLetter.isNotEmpty) {
+ final letterMorse = _currentLetter.join('');
+ _morseLetters.add(letterMorse);
+ _currentLetter.clear();
+
+ // Start word gap timer
+ _wordGapTimer = Timer(widget.wordGap, () {
+ _completeWord();
+ });
+
+ _updateOutput();
+ setState(() {});
+ }
+ }
+
+ void _completeWord() {
+ if (_morseLetters.isNotEmpty) {
+ final wordMorse = _morseLetters.join(' ');
+ _morseWords.add(wordMorse);
+ _morseLetters.clear();
+
+ _updateOutput();
+ setState(() {});
+ }
+ }
+
+ void _updateOutput() {
+ final currentMorse = _getCurrentMorseCode();
+
+ if (widget.autoConvertToText) {
+ final text = MorseCodec.morseToText(currentMorse);
+ _internalController.text = text;
+ widget.onTextChanged?.call(text);
+ } else {
+ _internalController.text = currentMorse;
+ widget.onTextChanged?.call(currentMorse);
+ }
+ }
+
+ String _getCurrentMorseCode() {
+ final words = [];
+
+ // Add completed words
+ words.addAll(_morseWords);
+
+ // Add current word in progress
+ if (_morseLetters.isNotEmpty || _currentLetter.isNotEmpty) {
+ final currentWordLetters = [];
+ currentWordLetters.addAll(_morseLetters);
+
+ // Add current letter in progress
+ if (_currentLetter.isNotEmpty) {
+ currentWordLetters.add(_currentLetter.join(''));
+ }
+
+ if (currentWordLetters.isNotEmpty) {
+ words.add(currentWordLetters.join(' '));
+ }
+ }
+
+ return words.join(' / ');
+ }
+
+ String _getCurrentLetterPreview() {
+ if (_currentLetter.isNotEmpty) {
+ final letterMorse = _currentLetter.join('');
+ final possibleChar = MorseCodec.morseToText(letterMorse);
+ if (possibleChar.isNotEmpty) {
+ return '$letterMorse → $possibleChar';
+ }
+ return letterMorse;
+ }
+ return '';
+ }
+
+ void _clearInput() {
+ _currentLetter.clear();
+ _morseWords.clear();
+ _morseLetters.clear();
+ _letterGapTimer?.cancel();
+ _wordGapTimer?.cancel();
+
+ _internalController.clear();
+ widget.onTextChanged?.call('');
+ widget.onClear?.call();
+
+ setState(() {});
+ }
+
+ Color _getCurrentFeedbackColor() {
+ if (_dotController.isAnimating) {
+ return Colors.green;
+ } else if (_dashController.isAnimating) {
+ return Colors.orange;
+ } else if (_spaceController.isAnimating) {
+ return Colors.purple;
+ }
+ return widget.feedbackColor;
+ }
+
+ double _getCurrentFeedbackValue() {
+ if (_dotController.isAnimating) {
+ return _dotController.value * 0.15;
+ } else if (_dashController.isAnimating) {
+ return _dashController.value * 0.15;
+ } else if (_spaceController.isAnimating) {
+ return _spaceController.value * 0.15;
+ }
+ return 0.02;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final currentMorse = _getCurrentMorseCode();
+ final letterPreview = _getCurrentLetterPreview();
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // Morse preview (optional)
+ if (widget.showMorsePreview) ...[
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Colors.grey[50],
+ border: Border.all(color: Colors.grey[300]!),
+ borderRadius: const BorderRadius.vertical(
+ top: Radius.circular(8),
+ ),
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(
+ Icons.radio_button_checked,
+ size: 16,
+ color: Colors.blue[600],
+ ),
+ const SizedBox(width: 6),
+ Text(
+ 'Morse Code:',
+ style: TextStyle(
+ fontSize: 12,
+ color: Colors.grey[700],
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 6),
+ SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: [
+ Text(
+ currentMorse.isEmpty
+ ? 'Tap below to input...'
+ : currentMorse,
+ style: TextStyle(
+ fontSize: 15,
+ fontFamily: 'monospace',
+ fontWeight: FontWeight.w600,
+ color: currentMorse.isEmpty
+ ? Colors.grey[500]
+ : Colors.black87,
+ ),
+ ),
+ if (letterPreview.isNotEmpty) ...[
+ const SizedBox(width: 12),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8,
+ vertical: 2,
+ ),
+ decoration: BoxDecoration(
+ color: Colors.blue[50],
+ border: Border.all(color: Colors.blue[200]!),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(
+ letterPreview,
+ style: TextStyle(
+ fontSize: 11,
+ color: Colors.blue[700],
+ fontFamily: 'monospace',
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+
+ // Text output field
+ TextField(
+ controller: _internalController,
+ readOnly: true,
+ maxLines: 3,
+ decoration:
+ widget.decoration ??
+ InputDecoration(
+ hintText: widget.autoConvertToText
+ ? 'Converted text will appear here...'
+ : 'Morse code will appear here...',
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.vertical(
+ top: widget.showMorsePreview
+ ? Radius.zero
+ : const Radius.circular(8),
+ bottom: Radius.zero,
+ ),
+ ),
+ suffixIcon: currentMorse.isNotEmpty
+ ? IconButton(
+ onPressed: _clearInput,
+ icon: const Icon(Icons.clear),
+ tooltip: 'Clear input',
+ )
+ : null,
+ ),
+ ),
+
+ // Tap detection area
+ AnimatedBuilder(
+ animation: Listenable.merge([
+ _dotController,
+ _dashController,
+ _spaceController,
+ ]),
+ builder: (context, child) {
+ return GestureDetector(
+ onTap: _onSingleTap,
+ onDoubleTap: _onDoubleTap,
+ onLongPress: _onLongPress,
+ child: Container(
+ height: widget.tapAreaHeight,
+ decoration: BoxDecoration(
+ color: _getCurrentFeedbackColor().withValues(
+ alpha: _getCurrentFeedbackValue(),
+ ),
+ border: Border.all(color: Colors.grey[300]!),
+ borderRadius: const BorderRadius.vertical(
+ bottom: Radius.circular(8),
+ ),
+ ),
+ child: Stack(
+ children: [
+ Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.touch_app,
+ size: 32,
+ color: Colors.grey[600],
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Tap for Morse Input',
+ style: TextStyle(
+ fontSize: 16,
+ color: Colors.grey[700],
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ '1 tap = • | 2 taps = — | Hold = space',
+ style: TextStyle(
+ fontSize: 12,
+ color: Colors.grey[600],
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ // Current letter being typed
+ if (_currentLetter.isNotEmpty)
+ Positioned(
+ top: 8,
+ right: 8,
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10,
+ vertical: 4,
+ ),
+ decoration: BoxDecoration(
+ color: Colors.black87,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Colors.white24),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ Icons.edit,
+ color: Colors.yellow[300],
+ size: 12,
+ ),
+ const SizedBox(width: 4),
+ Text(
+ _currentLetter.join(''),
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 12,
+ fontFamily: 'monospace',
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ // Gesture feedback indicators
+ if (_dotController.isAnimating)
+ const Positioned(
+ bottom: 8,
+ left: 8,
+ child: Icon(
+ Icons.circle,
+ color: Colors.green,
+ size: 12,
+ ),
+ ),
+ if (_dashController.isAnimating)
+ const Positioned(
+ bottom: 8,
+ left: 28,
+ child: Icon(
+ Icons.remove,
+ color: Colors.orange,
+ size: 16,
+ ),
+ ),
+ if (_spaceController.isAnimating)
+ const Positioned(
+ bottom: 8,
+ left: 52,
+ child: Icon(
+ Icons.space_bar,
+ color: Colors.purple,
+ size: 16,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ ],
+ );
+ }
+}
diff --git a/packages/morse_tap/morse_tap.png b/packages/morse_tap/morse_tap.png
new file mode 100644
index 0000000..7bc1278
Binary files /dev/null and b/packages/morse_tap/morse_tap.png differ
diff --git a/packages/morse_tap/pubspec.yaml b/packages/morse_tap/pubspec.yaml
new file mode 100644
index 0000000..17858ed
--- /dev/null
+++ b/packages/morse_tap/pubspec.yaml
@@ -0,0 +1,67 @@
+name: morse_tap
+description: "A Flutter package for Morse code input using intuitive gestures. Detect patterns, convert text in real-time, and create interactive Morse experiences."
+version: 0.0.3
+homepage: https://github.com/nonstopio/flutter_forge/tree/main/packages/morse_tap
+repository: https://github.com/nonstopio/flutter_forge
+
+screenshots:
+ - description: Morse Tap package demonstration showing tap detector and text input
+ path: morse_tap.png
+
+environment:
+ sdk: ^3.8.1
+ flutter: ">=1.17.0"
+
+platforms:
+ android:
+ ios:
+ web:
+ linux:
+ macos:
+ windows:
+
+dependencies:
+ flutter:
+ sdk: flutter
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+ flutter_lints: ^5.0.0
+
+# For information on the generic Dart part of this file, see the
+# following page: https://dart.dev/tools/pub/pubspec
+
+# The following section is specific to Flutter packages.
+flutter:
+
+ # To add assets to your package, add an assets section, like this:
+ # assets:
+ # - images/a_dot_burr.jpeg
+ # - images/a_dot_ham.jpeg
+ #
+ # For details regarding assets in packages, see
+ # https://flutter.dev/to/asset-from-package
+ #
+ # An image asset can refer to one or more resolution-specific "variants", see
+ # https://flutter.dev/to/resolution-aware-images
+
+ # To add custom fonts to your package, add a fonts section here,
+ # in this "flutter" section. Each entry in this list should have a
+ # "family" key with the font family name, and a "fonts" key with a
+ # list giving the asset and other descriptors for the font. For
+ # example:
+ # fonts:
+ # - family: Schyler
+ # fonts:
+ # - asset: fonts/Schyler-Regular.ttf
+ # - asset: fonts/Schyler-Italic.ttf
+ # style: italic
+ # - family: Trajan Pro
+ # fonts:
+ # - asset: fonts/TrajanPro.ttf
+ # - asset: fonts/TrajanPro_Bold.ttf
+ # weight: 700
+ #
+ # For details regarding fonts in packages, see
+ # https://flutter.dev/to/font-from-package
diff --git a/packages/morse_tap/test/morse_tap_test.dart b/packages/morse_tap/test/morse_tap_test.dart
new file mode 100644
index 0000000..7c496ba
--- /dev/null
+++ b/packages/morse_tap/test/morse_tap_test.dart
@@ -0,0 +1,73 @@
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:morse_tap/morse_tap.dart';
+
+void main() {
+ group('MorseCodec', () {
+ test('converts text to Morse code', () {
+ expect('SOS'.toMorseCode(), '... --- ...');
+ expect('HELLO'.toMorseCode(), '.... . .-.. .-.. ---');
+ expect('TEST'.toMorseCode(), '- . ... -');
+ });
+
+ test('converts Morse code to text', () {
+ expect('... --- ...'.fromMorseCode(), 'SOS');
+ expect('.... . .-.. .-.. ---'.fromMorseCode(), 'HELLO');
+ expect('- . ... -'.fromMorseCode(), 'TEST');
+ });
+
+ test('validates Morse sequences', () {
+ expect('... --- ...'.isValidMorseSequence(), true);
+ expect('... xyz ...'.isValidMorseSequence(), false);
+ expect(''.isValidMorseSequence(), true);
+ });
+
+ test('validates Morse input characters', () {
+ expect('... --- ...'.isValidMorseInput(), true);
+ expect('... abc ...'.isValidMorseInput(), false);
+ expect('.-.. --- ...- .'.isValidMorseInput(), true);
+ });
+ });
+
+ group('MorseCodec direct methods', () {
+ test('textToMorse handles multiple words', () {
+ expect(
+ MorseCodec.textToMorse('HELLO WORLD'),
+ '.... . .-.. .-.. --- / .-- --- .-. .-.. -..',
+ );
+ });
+
+ test('morseToText handles multiple words', () {
+ expect(MorseCodec.morseToText('... --- ... / - . ... -'), 'SOS TEST');
+ });
+
+ test('getCharacterDuration returns correct durations', () {
+ expect(
+ MorseCodec.getCharacterDuration('.'),
+ const Duration(milliseconds: 100),
+ );
+ expect(
+ MorseCodec.getCharacterDuration('-'),
+ const Duration(milliseconds: 300),
+ );
+ expect(
+ MorseCodec.getCharacterDuration('x'),
+ const Duration(milliseconds: 0),
+ );
+ });
+
+ test('splitMorseSequence extracts dots and dashes', () {
+ expect(MorseCodec.splitMorseSequence('... --- ...'), [
+ '.',
+ '.',
+ '.',
+ '-',
+ '-',
+ '-',
+ '.',
+ '.',
+ '.',
+ ]);
+ });
+ });
+}