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. | [![ns_utils](https://img.shields.io/pub/v/ns_utils.svg?label=ns_utils&logo=dart&color=blue&style=for-the-badge)](https://pub.dev/packages/ns_utils) | | [![ns_intl_phone_input](https://img.shields.io/pub/v/ns_intl_phone_input.svg?label=ns_intl_phone_input&logo=dart&color=blue&style=for-the-badge)](https://pub.dev/packages/ns_intl_phone_input) | | [![dzod](https://img.shields.io/pub/v/dzod.svg?label=dzod&logo=dart&color=blue&style=for-the-badge)](https://pub.dev/packages/dzod) | -| [![html_rich_text](https://img.shields.io/pub/v/html_rich_text.svg?label=html_rich_text&logo=dart&color=blue&style=for-the-badge)](https://pub.dev/packages/html_rich_text) | +| [![html_rich_text](https://img.shields.io/pub/v/html_rich_text.svg?label=html_rich_text&logo=dart&color=blue&style=for-the-badge)](https://pub.dev/packages/html_rich_text) | +| [![morse_tap](https://img.shields.io/pub/v/morse_tap.svg?label=morse_tap&logo=dart&color=blue&style=for-the-badge)](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 | [![ns_intl_phone_input pub.dev badge](https://img.shields.io/pub/v/ns_intl_phone_input.svg)](https://pub.dev/packages/ns_intl_phone_input) | [`🔗`](ns_intl_phone_input/README.md) | | html_rich_text | [![html_rich_text pub.dev badge](https://img.shields.io/pub/v/html_rich_text.svg)](https://pub.dev/packages/html_rich_text) | [`🔗`](html_rich_text/README.md) | | dzod | [![dzod pub.dev badge](https://img.shields.io/pub/v/dzod.svg)](https://pub.dev/packages/dzod) | [`🔗`](dzod/README.md) | +| morse_tap | [![morse_tap pub.dev badge](https://img.shields.io/pub/v/morse_tap.svg)](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 Logo + +

NonStop

+

Digital Product Development Experts for Startups & Enterprises

+

+ About | + Website +

+

+ +# morse_tap + +[![Build Status](https://img.shields.io/pub/v/morse_tap.svg)](https://github.com/nonstopio/flutter_forge/tree/main/packages/morse_tap) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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. + +![Morse Tap Demo](morse_tap.png) + +## 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!** + +[![LinkedIn](https://img.shields.io/badge/LinkedIn-0077B5?style=for-the-badge&logo=linkedin&logoColor=white)](https://www.linkedin.com/company/nonstop-io) +[![X.com](https://img.shields.io/badge/X-000000?style=for-the-badge&logo=x&logoColor=white)](https://x.com/NonStopio) +[![Instagram](https://img.shields.io/badge/Instagram-E4405F?style=for-the-badge&logo=instagram&logoColor=white)](https://www.instagram.com/nonstopio_technologies/) +[![YouTube](https://img.shields.io/badge/YouTube-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/@NonStopioTechnology) +[![Email](https://img.shields.io/badge/Email-D14836?style=for-the-badge&logo=gmail&logoColor=white)](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('... --- ...'), [ + '.', + '.', + '.', + '-', + '-', + '-', + '.', + '.', + '.', + ]); + }); + }); +}