From 6668786b67600d7bada9479e5560137f24482507 Mon Sep 17 00:00:00 2001
From: Ajay Kumar
Date: Mon, 11 Aug 2025 00:17:21 +0530
Subject: [PATCH 01/18] feat(packages): add morse_tap package with
gesture-based input
- Implement MorseTapDetector widget with gesture recognition
- Add MorseTextInput for real-time text conversion
- Create MorseCodec algorithm with comprehensive character support
- Include string extensions for easy Morse code conversion
- Add complete example app with 3 demo screens
- Implement gesture system: single tap = dot, double tap = dash, long press = space
- Include 8 comprehensive test cases
- Add full documentation and README
---
packages/morse_tap/.gitignore | 31 +
packages/morse_tap/.metadata | 10 +
packages/morse_tap/CHANGELOG.md | 3 +
packages/morse_tap/LICENSE | 1 +
packages/morse_tap/README.md | 310 ++++++++
packages/morse_tap/example/.gitignore | 52 ++
packages/morse_tap/example/.metadata | 45 ++
packages/morse_tap/example/README.md | 16 +
.../morse_tap/example/analysis_options.yaml | 28 +
packages/morse_tap/example/lib/main.dart | 694 ++++++++++++++++++
packages/morse_tap/example/main.dart | 688 +++++++++++++++++
packages/morse_tap/example/pubspec.yaml | 90 +++
packages/morse_tap/lib/morse_tap.dart | 10 +
.../morse_tap/lib/src/morse_algorithm.dart | 194 +++++
.../morse_tap/lib/src/morse_extensions.dart | 86 +++
.../lib/src/widgets/morse_tap_detector.dart | 339 +++++++++
.../lib/src/widgets/morse_text_input.dart | 521 +++++++++++++
packages/morse_tap/pubspec.yaml | 54 ++
packages/morse_tap/test/morse_tap_test.dart | 57 ++
19 files changed, 3229 insertions(+)
create mode 100644 packages/morse_tap/.gitignore
create mode 100644 packages/morse_tap/.metadata
create mode 100644 packages/morse_tap/CHANGELOG.md
create mode 100644 packages/morse_tap/LICENSE
create mode 100644 packages/morse_tap/README.md
create mode 100644 packages/morse_tap/example/.gitignore
create mode 100644 packages/morse_tap/example/.metadata
create mode 100644 packages/morse_tap/example/README.md
create mode 100644 packages/morse_tap/example/analysis_options.yaml
create mode 100644 packages/morse_tap/example/lib/main.dart
create mode 100644 packages/morse_tap/example/main.dart
create mode 100644 packages/morse_tap/example/pubspec.yaml
create mode 100644 packages/morse_tap/lib/morse_tap.dart
create mode 100644 packages/morse_tap/lib/src/morse_algorithm.dart
create mode 100644 packages/morse_tap/lib/src/morse_extensions.dart
create mode 100644 packages/morse_tap/lib/src/widgets/morse_tap_detector.dart
create mode 100644 packages/morse_tap/lib/src/widgets/morse_text_input.dart
create mode 100644 packages/morse_tap/pubspec.yaml
create mode 100644 packages/morse_tap/test/morse_tap_test.dart
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..41cc7d8
--- /dev/null
+++ b/packages/morse_tap/CHANGELOG.md
@@ -0,0 +1,3 @@
+## 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..ba75c69
--- /dev/null
+++ b/packages/morse_tap/LICENSE
@@ -0,0 +1 @@
+TODO: Add your license here.
diff --git a/packages/morse_tap/README.md b/packages/morse_tap/README.md
new file mode 100644
index 0000000..f246d0b
--- /dev/null
+++ b/packages/morse_tap/README.md
@@ -0,0 +1,310 @@
+
+
+
+
+
NonStop
+ Digital Product Development Experts for Startups & Enterprises
+
+ About |
+ Website
+
+
+
+# morse_tap
+
+[](https://github.com/nonstopio/flutter_forge/tree/main/packages/morse_tap)
+[](https://opensource.org/licenses/MIT)
+
+A Flutter package that provides Morse code input functionality using intuitive gestures. Create interactive Morse code experiences with single taps for dots, double taps for dashes, and long presses for spaces.
+
+## Features
+
+✨ **MorseTapDetector** - Widget that detects specific Morse code patterns using gestures
+🎯 **MorseTextInput** - Real-time gesture-to-text conversion widget
+🔄 **String Extensions** - Convert any string to/from Morse code
+⚡ **Fast Algorithm** - Efficient Morse code conversion with comprehensive character support
+🎨 **Intuitive Gestures** - Single tap = dot, double tap = dash, long press = space
+
+## Requirements
+
+- Flutter >=3.19.0
+- Dart >=3.3.0 <4.0.0
+
+## Installation
+
+Add to your `pubspec.yaml`:
+
+```yaml
+dependencies:
+ morse_tap: ^0.0.1
+```
+
+Or install via command line:
+
+```bash
+flutter pub add morse_tap
+```
+
+## Quick Start
+
+Import the package:
+
+```dart
+import 'package:morse_tap/morse_tap.dart';
+```
+
+## 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");
+ },
+ 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 tap timing thresholds:
+
+```dart
+MorseTapDetector(
+ expectedMorseCode: "...",
+ dotThreshold: Duration(milliseconds: 150), // Shorter for dots
+ dashThreshold: Duration(milliseconds: 400), // Longer for dashes
+ letterGap: Duration(milliseconds: 600), // Gap between letters
+ sequenceTimeout: Duration(seconds: 5), // Reset timeout
+ onTap: () => print("Correct!"),
+ child: MyButton(),
+)
+```
+
+### 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**: . , ? ' ! / ( ) & : ; = + - _ " $ @
+
+## Morse Code Reference
+
+| Character | Morse Code |
+|-----------|------------|
+| A | .- |
+| B | -... |
+| C | -.-. |
+| S | ... |
+| O | --- |
+| 0 | ----- |
+| 1 | .---- |
+| 9 | ----. |
+
+*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,
+ onTap: () => showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: Text("Help Requested!"),
+ content: Text("Someone needs assistance."),
+ ),
+ ),
+ child: EmergencyButton(),
+)
+```
+
+### Multiple Pattern Detection
+
+Handle different patterns:
+
+```dart
+class MultiPatternDetector extends StatelessWidget {
+ final Map patterns = {
+ 'SOS': '... --- ...',
+ 'OK': '--- -.-',
+ 'YES': '-.-- . ...',
+ };
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ children: patterns.entries.map((entry) {
+ return MorseTapDetector(
+ expectedMorseCode: entry.value,
+ onTap: () => handlePattern(entry.key),
+ child: PatternButton(label: entry.key),
+ );
+ }).toList(),
+ );
+ }
+
+ void handlePattern(String pattern) {
+ print("Pattern $pattern detected!");
+ }
+}
+```
+
+## 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!
+
+A big thank you to all our contributors! 🙌
+
+
+
+
+---
+
+## 🔗 Connect with NonStop
+
+
+
+**Stay connected and get the latest updates!**
+
+[](https://www.linkedin.com/company/nonstop-io)
+[](https://x.com/NonStopio)
+[](https://www.instagram.com/nonstopio_technologies/)
+[](https://www.youtube.com/@NonStopioTechnology)
+[](mailto:contact@nonstopio.com)
+
+
+
+---
+
+
+
+> ⭐ Star us on [GitHub](https://github.com/nonstopio/flutter_forge) if this helped you!
+
+
+
+## 📜 License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+
+
+> 🎉 [Founded by Ajay Kumar](https://github.com/ProjectAJ14) 🎉**
+
+
\ No newline at end of file
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..6a623a4
--- /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: "d7b523b356d15fb81e7d340bbe52b47f93937323"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
+ base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
+ - platform: android
+ create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
+ base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
+ - platform: ios
+ create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
+ base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
+ - platform: linux
+ create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
+ base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
+ - platform: macos
+ create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
+ base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
+ - platform: web
+ create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
+ base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
+ - platform: windows
+ create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
+ base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
+
+ # 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/main.dart b/packages/morse_tap/example/lib/main.dart
new file mode 100644
index 0000000..371ca66
--- /dev/null
+++ b/packages/morse_tap/example/lib/main.dart
@@ -0,0 +1,694 @@
+import 'package:flutter/material.dart';
+import 'package:morse_tap/morse_tap.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 = '';
+
+ 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';
+ });
+ }
+
+ @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',
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ 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 = '';
+ });
+ }
+ },
+ ),
+ );
+ }).toList(),
+ ),
+ ),
+
+ const SizedBox(height: 24),
+
+ // Morse tap detector
+ Expanded(
+ child: MorseTapDetector(
+ expectedMorseCode: _targets[_currentTarget]!,
+ onCorrectSequence: _onCorrectSequence,
+ onIncorrectSequence: _onIncorrectSequence,
+ onSequenceTimeout: _onTimeout,
+ onDotAdded: _onDotAdded,
+ onDashAdded: _onDashAdded,
+ onSpaceAdded: _onSpaceAdded,
+ feedbackColor: _buttonColor,
+ 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/main.dart b/packages/morse_tap/example/main.dart
new file mode 100644
index 0000000..07153fb
--- /dev/null
+++ b/packages/morse_tap/example/main.dart
@@ -0,0 +1,688 @@
+import 'package:flutter/material.dart';
+import 'package:morse_tap/morse_tap.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 = '';
+
+ 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';
+ });
+ }
+
+ @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',
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ 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 = '';
+ });
+ }
+ },
+ ),
+ );
+ }).toList(),
+ ),
+ ),
+
+ const SizedBox(height: 24),
+
+ // Morse tap detector
+ Expanded(
+ child: MorseTapDetector(
+ expectedMorseCode: _targets[_currentTarget]!,
+ onCorrectSequence: _onCorrectSequence,
+ onIncorrectSequence: _onIncorrectSequence,
+ onSequenceTimeout: _onTimeout,
+ onDotAdded: _onDotAdded,
+ onDashAdded: _onDashAdded,
+ onSpaceAdded: _onSpaceAdded,
+ feedbackColor: _buttonColor,
+ 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..8d4e3e0
--- /dev/null
+++ b/packages/morse_tap/lib/morse_tap.dart
@@ -0,0 +1,10 @@
+/// 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/morse_algorithm.dart';
+export 'src/morse_extensions.dart';
+export 'src/widgets/morse_tap_detector.dart';
+export 'src/widgets/morse_text_input.dart';
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/widgets/morse_tap_detector.dart b/packages/morse_tap/lib/src/widgets/morse_tap_detector.dart
new file mode 100644
index 0000000..7bdd450
--- /dev/null
+++ b/packages/morse_tap/lib/src/widgets/morse_tap_detector.dart
@@ -0,0 +1,339 @@
+import 'dart:async';
+import 'package:flutter/material.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.
+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
+ /// [sequenceTimeout] Timeout for incomplete sequences
+ /// [showVisualFeedback] Whether to show visual feedback during input
+ /// [feedbackColor] Color for visual feedback
+ const MorseTapDetector({
+ super.key,
+ required this.expectedMorseCode,
+ required this.onCorrectSequence,
+ required this.child,
+ this.sequenceTimeout = const Duration(seconds: 10),
+ this.showVisualFeedback = true,
+ this.feedbackColor = Colors.blue,
+ this.onIncorrectSequence,
+ this.onSequenceTimeout,
+ 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 for incomplete sequences
+ final Duration sequenceTimeout;
+
+ /// Whether to show visual feedback during tap input
+ final bool showVisualFeedback;
+
+ /// Color for visual feedback overlay
+ final Color feedbackColor;
+
+ /// Callback for when an incorrect sequence is detected
+ final VoidCallback? onIncorrectSequence;
+
+ /// Callback for when a sequence times out
+ final VoidCallback? onSequenceTimeout;
+
+ /// 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;
+
+ @override
+ State createState() => _MorseTapDetectorState();
+}
+
+class _MorseTapDetectorState extends State
+ with TickerProviderStateMixin {
+ final List _currentSequence = [];
+ Timer? _timeoutTimer;
+
+ late AnimationController _feedbackController;
+
+ late AnimationController _dotController;
+ late AnimationController _dashController;
+ late AnimationController _spaceController;
+
+ @override
+ void initState() {
+ super.initState();
+
+ // Feedback animation controller (unused but kept for potential future use)
+ _feedbackController = AnimationController(
+ duration: const Duration(milliseconds: 100),
+ vsync: this,
+ );
+
+ // Individual gesture feedback animations
+ _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() {
+ _timeoutTimer?.cancel();
+ _feedbackController.dispose();
+ _dotController.dispose();
+ _dashController.dispose();
+ _spaceController.dispose();
+ super.dispose();
+ }
+
+ void _startSequenceTimeout() {
+ _timeoutTimer?.cancel();
+ _timeoutTimer = Timer(widget.sequenceTimeout, () {
+ _resetSequence();
+ widget.onSequenceTimeout?.call();
+ });
+ }
+
+ void _resetSequence() {
+ _currentSequence.clear();
+ _timeoutTimer?.cancel();
+ if (mounted) {
+ setState(() {});
+ }
+ }
+
+ void _addMorseCharacter(String character) {
+ _currentSequence.add(character);
+ _startSequenceTimeout();
+ setState(() {});
+
+ // Check if sequence matches expected pattern
+ _checkSequence();
+ }
+
+ void _onSingleTap() {
+ // Single tap = dot
+ _addMorseCharacter('.');
+ widget.onDotAdded?.call();
+
+ // Visual feedback
+ if (widget.showVisualFeedback) {
+ _dotController.forward().then((_) => _dotController.reverse());
+ }
+ }
+
+ void _onDoubleTap() {
+ // Double tap = dash
+ _addMorseCharacter('-');
+ widget.onDashAdded?.call();
+
+ // Visual feedback
+ if (widget.showVisualFeedback) {
+ _dashController.forward().then((_) => _dashController.reverse());
+ }
+ }
+
+ void _onLongPress() {
+ // Long press = space (letter separator)
+ _addMorseCharacter(' ');
+ widget.onSpaceAdded?.call();
+
+ // Visual feedback
+ if (widget.showVisualFeedback) {
+ _spaceController.forward().then((_) => _spaceController.reverse());
+ }
+ }
+
+ void _checkSequence() {
+ final currentMorse = _currentSequence.join('');
+ final expectedMorse = widget.expectedMorseCode;
+
+ if (currentMorse == expectedMorse) {
+ // Correct sequence detected!
+ _resetSequence();
+ widget.onCorrectSequence();
+ } else if (currentMorse.length >= expectedMorse.length ||
+ !expectedMorse.startsWith(currentMorse)) {
+ // Sequence is wrong or too long
+ _resetSequence();
+ widget.onIncorrectSequence?.call();
+ }
+ // Otherwise, continue waiting for more input
+ }
+
+ String get _currentMorseDisplay {
+ return _currentSequence.join('');
+ }
+
+ 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.3;
+ } else if (_dashController.isAnimating) {
+ return _dashController.value * 0.3;
+ } else if (_spaceController.isAnimating) {
+ return _spaceController.value * 0.3;
+ }
+ return 0.0;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: _onSingleTap,
+ onDoubleTap: _onDoubleTap,
+ onLongPress: _onLongPress,
+ child: AnimatedBuilder(
+ animation: Listenable.merge([
+ _dotController,
+ _dashController,
+ _spaceController,
+ ]),
+ builder: (context, child) {
+ return Stack(
+ children: [
+ // Base widget with feedback overlay
+ Container(
+ decoration: BoxDecoration(
+ color: widget.showVisualFeedback
+ ? _getCurrentFeedbackColor().withValues(
+ alpha: _getCurrentFeedbackValue(),
+ )
+ : null,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: widget.child,
+ ),
+
+ // Current sequence display
+ if (_currentSequence.isNotEmpty)
+ Positioned(
+ top: 8,
+ right: 8,
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 6,
+ ),
+ decoration: BoxDecoration(
+ color: Colors.black87,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: Colors.white24),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ Icons.radio_button_checked,
+ color: Colors.blue[300],
+ size: 12,
+ ),
+ const SizedBox(width: 6),
+ Text(
+ _currentMorseDisplay,
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 14,
+ fontFamily: 'monospace',
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ // Gesture hints overlay
+ if (_currentSequence.isEmpty)
+ Positioned(
+ bottom: 8,
+ left: 8,
+ right: 8,
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8,
+ vertical: 4,
+ ),
+ decoration: BoxDecoration(
+ color: Colors.black54,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: const Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: [
+ Text(
+ '1 tap = •',
+ style: TextStyle(
+ color: Colors.white70,
+ fontSize: 10,
+ ),
+ ),
+ Text(
+ '2 taps = —',
+ style: TextStyle(
+ color: Colors.white70,
+ fontSize: 10,
+ ),
+ ),
+ Text(
+ 'Hold = space',
+ style: TextStyle(
+ color: Colors.white70,
+ fontSize: 10,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ );
+ }
+}
\ No newline at end of file
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..63b60de
--- /dev/null
+++ b/packages/morse_tap/lib/src/widgets/morse_text_input.dart
@@ -0,0 +1,521 @@
+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(
+ height: 80,
+ 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(
+ 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: 4),
+ Expanded(
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: [
+ Text(
+ currentMorse.isEmpty ? 'Tap below to input...' : currentMorse,
+ style: TextStyle(
+ fontSize: 16,
+ 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: 12,
+ 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),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ ],
+ );
+ }
+}
\ No newline at end of file
diff --git a/packages/morse_tap/pubspec.yaml b/packages/morse_tap/pubspec.yaml
new file mode 100644
index 0000000..da7cd8a
--- /dev/null
+++ b/packages/morse_tap/pubspec.yaml
@@ -0,0 +1,54 @@
+name: morse_tap
+description: "A Melos-managed project for mono-repo, created using NonStop CLI."
+version: 0.0.1
+homepage:
+
+environment:
+ sdk: ^3.8.1
+ flutter: ">=1.17.0"
+
+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..240cc73
--- /dev/null
+++ b/packages/morse_tap/test/morse_tap_test.dart
@@ -0,0 +1,57 @@
+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('... --- ...'),
+ ['.','.','.','-','-','-','.','.','.']);
+ });
+ });
+}
From 0346792b34efdf9136df440a19b45d54626f60bb Mon Sep 17 00:00:00 2001
From: Ajay Kumar
Date: Fri, 22 Aug 2025 18:05:49 +0530
Subject: [PATCH 02/18] refactor(morse_tap): remove visual feedback from
MorseTapDetector
- Remove animation controllers and visual overlays
- Simplify widget to pure gesture detection
- Clean up unused visual feedback parameters
- Update tests to reflect removed visual features
---
.../lib/src/widgets/morse_tap_detector.dart | 254 +++---------------
packages/morse_tap/test/morse_tap_test.dart | 40 ++-
2 files changed, 66 insertions(+), 228 deletions(-)
diff --git a/packages/morse_tap/lib/src/widgets/morse_tap_detector.dart b/packages/morse_tap/lib/src/widgets/morse_tap_detector.dart
index 7bdd450..6c635c8 100644
--- a/packages/morse_tap/lib/src/widgets/morse_tap_detector.dart
+++ b/packages/morse_tap/lib/src/widgets/morse_tap_detector.dart
@@ -6,30 +6,30 @@ import 'package:flutter/material.dart';
///
/// The widget uses discrete gestures:
/// - Single tap (onTap) = dot (.)
-/// - Double tap (onDoubleTap) = dash (-)
+/// - Double tap (onDoubleTap) = dash (-)
/// - Long press (onLongPress) = space between letters
-///
-/// It validates the tapped sequence against the expected Morse code pattern
+///
+/// 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
- /// [sequenceTimeout] Timeout for incomplete sequences
- /// [showVisualFeedback] Whether to show visual feedback during input
- /// [feedbackColor] Color for visual feedback
+ /// [inputTimeout] Timeout duration to wait for the next input character
const MorseTapDetector({
super.key,
required this.expectedMorseCode,
required this.onCorrectSequence,
required this.child,
- this.sequenceTimeout = const Duration(seconds: 10),
- this.showVisualFeedback = true,
- this.feedbackColor = Colors.blue,
+ this.inputTimeout = const Duration(seconds: 10),
this.onIncorrectSequence,
- this.onSequenceTimeout,
+ this.onInputTimeout,
+ this.onSequenceChange,
this.onDotAdded,
this.onDashAdded,
this.onSpaceAdded,
@@ -44,20 +44,16 @@ class MorseTapDetector extends StatefulWidget {
/// The child widget to detect taps on
final Widget child;
- /// Timeout duration for incomplete sequences
- final Duration sequenceTimeout;
-
- /// Whether to show visual feedback during tap input
- final bool showVisualFeedback;
-
- /// Color for visual feedback overlay
- final Color feedbackColor;
+ /// 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;
/// Callback for when an incorrect sequence is detected
final VoidCallback? onIncorrectSequence;
- /// Callback for when a sequence times out
- final VoidCallback? onSequenceTimeout;
+ /// Callback for when input times out (no input received within timeout duration)
+ final VoidCallback? onInputTimeout;
/// Callback when a dot is added
final VoidCallback? onDotAdded;
@@ -68,79 +64,52 @@ class MorseTapDetector extends StatefulWidget {
/// 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
- with TickerProviderStateMixin {
+class _MorseTapDetectorState extends State {
final List _currentSequence = [];
Timer? _timeoutTimer;
-
- late AnimationController _feedbackController;
-
- late AnimationController _dotController;
- late AnimationController _dashController;
- late AnimationController _spaceController;
@override
void initState() {
super.initState();
-
- // Feedback animation controller (unused but kept for potential future use)
- _feedbackController = AnimationController(
- duration: const Duration(milliseconds: 100),
- vsync: this,
- );
-
- // Individual gesture feedback animations
- _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() {
_timeoutTimer?.cancel();
- _feedbackController.dispose();
- _dotController.dispose();
- _dashController.dispose();
- _spaceController.dispose();
super.dispose();
}
- void _startSequenceTimeout() {
+ void _startInputTimeout() {
_timeoutTimer?.cancel();
- _timeoutTimer = Timer(widget.sequenceTimeout, () {
+ _timeoutTimer = Timer(widget.inputTimeout, () {
_resetSequence();
- widget.onSequenceTimeout?.call();
+ widget.onInputTimeout?.call();
});
}
void _resetSequence() {
_currentSequence.clear();
_timeoutTimer?.cancel();
- if (mounted) {
- setState(() {});
- }
+ // Notify sequence change with empty string on reset
+ widget.onSequenceChange?.call('');
}
void _addMorseCharacter(String character) {
_currentSequence.add(character);
- _startSequenceTimeout();
- setState(() {});
-
+ _startInputTimeout();
+
+ // Notify sequence change
+ final currentMorse = _currentSequence.join('');
+ widget.onSequenceChange?.call(currentMorse);
+
// Check if sequence matches expected pattern
_checkSequence();
}
@@ -149,33 +118,18 @@ class _MorseTapDetectorState extends State
// Single tap = dot
_addMorseCharacter('.');
widget.onDotAdded?.call();
-
- // Visual feedback
- if (widget.showVisualFeedback) {
- _dotController.forward().then((_) => _dotController.reverse());
- }
}
void _onDoubleTap() {
// Double tap = dash
_addMorseCharacter('-');
widget.onDashAdded?.call();
-
- // Visual feedback
- if (widget.showVisualFeedback) {
- _dashController.forward().then((_) => _dashController.reverse());
- }
}
void _onLongPress() {
// Long press = space (letter separator)
_addMorseCharacter(' ');
widget.onSpaceAdded?.call();
-
- // Visual feedback
- if (widget.showVisualFeedback) {
- _spaceController.forward().then((_) => _spaceController.reverse());
- }
}
void _checkSequence() {
@@ -184,156 +138,24 @@ class _MorseTapDetectorState extends State
if (currentMorse == expectedMorse) {
// Correct sequence detected!
- _resetSequence();
widget.onCorrectSequence();
- } else if (currentMorse.length >= expectedMorse.length ||
- !expectedMorse.startsWith(currentMorse)) {
- // Sequence is wrong or too long
_resetSequence();
+ } else if (currentMorse.length >= expectedMorse.length ||
+ !expectedMorse.startsWith(currentMorse)) {
+ // Sequence is wrong or too long
widget.onIncorrectSequence?.call();
+ _resetSequence();
}
// Otherwise, continue waiting for more input
}
- String get _currentMorseDisplay {
- return _currentSequence.join('');
- }
-
- 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.3;
- } else if (_dashController.isAnimating) {
- return _dashController.value * 0.3;
- } else if (_spaceController.isAnimating) {
- return _spaceController.value * 0.3;
- }
- return 0.0;
- }
-
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onSingleTap,
onDoubleTap: _onDoubleTap,
onLongPress: _onLongPress,
- child: AnimatedBuilder(
- animation: Listenable.merge([
- _dotController,
- _dashController,
- _spaceController,
- ]),
- builder: (context, child) {
- return Stack(
- children: [
- // Base widget with feedback overlay
- Container(
- decoration: BoxDecoration(
- color: widget.showVisualFeedback
- ? _getCurrentFeedbackColor().withValues(
- alpha: _getCurrentFeedbackValue(),
- )
- : null,
- borderRadius: BorderRadius.circular(8),
- ),
- child: widget.child,
- ),
-
- // Current sequence display
- if (_currentSequence.isNotEmpty)
- Positioned(
- top: 8,
- right: 8,
- child: Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 12,
- vertical: 6,
- ),
- decoration: BoxDecoration(
- color: Colors.black87,
- borderRadius: BorderRadius.circular(16),
- border: Border.all(color: Colors.white24),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(
- Icons.radio_button_checked,
- color: Colors.blue[300],
- size: 12,
- ),
- const SizedBox(width: 6),
- Text(
- _currentMorseDisplay,
- style: const TextStyle(
- color: Colors.white,
- fontSize: 14,
- fontFamily: 'monospace',
- fontWeight: FontWeight.w600,
- ),
- ),
- ],
- ),
- ),
- ),
-
- // Gesture hints overlay
- if (_currentSequence.isEmpty)
- Positioned(
- bottom: 8,
- left: 8,
- right: 8,
- child: Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 8,
- vertical: 4,
- ),
- decoration: BoxDecoration(
- color: Colors.black54,
- borderRadius: BorderRadius.circular(12),
- ),
- child: const Row(
- mainAxisAlignment: MainAxisAlignment.spaceEvenly,
- children: [
- Text(
- '1 tap = •',
- style: TextStyle(
- color: Colors.white70,
- fontSize: 10,
- ),
- ),
- Text(
- '2 taps = —',
- style: TextStyle(
- color: Colors.white70,
- fontSize: 10,
- ),
- ),
- Text(
- 'Hold = space',
- style: TextStyle(
- color: Colors.white70,
- fontSize: 10,
- ),
- ),
- ],
- ),
- ),
- ),
- ],
- );
- },
- ),
+ child: widget.child,
);
}
-}
\ No newline at end of file
+}
diff --git a/packages/morse_tap/test/morse_tap_test.dart b/packages/morse_tap/test/morse_tap_test.dart
index 240cc73..7c496ba 100644
--- a/packages/morse_tap/test/morse_tap_test.dart
+++ b/packages/morse_tap/test/morse_tap_test.dart
@@ -31,27 +31,43 @@ void main() {
group('MorseCodec direct methods', () {
test('textToMorse handles multiple words', () {
- expect(MorseCodec.textToMorse('HELLO WORLD'),
- '.... . .-.. .-.. --- / .-- --- .-. .-.. -..');
+ expect(
+ MorseCodec.textToMorse('HELLO WORLD'),
+ '.... . .-.. .-.. --- / .-- --- .-. .-.. -..',
+ );
});
test('morseToText handles multiple words', () {
- expect(MorseCodec.morseToText('... --- ... / - . ... -'),
- 'SOS TEST');
+ 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));
+ 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('... --- ...'),
- ['.','.','.','-','-','-','.','.','.']);
+ expect(MorseCodec.splitMorseSequence('... --- ...'), [
+ '.',
+ '.',
+ '.',
+ '-',
+ '-',
+ '-',
+ '.',
+ '.',
+ '.',
+ ]);
});
});
}
From 2034f6103637c487a8723850b2300da938053d7a Mon Sep 17 00:00:00 2001
From: Ajay Kumar
Date: Fri, 22 Aug 2025 18:06:32 +0530
Subject: [PATCH 03/18] feat(examples): integrate onSequenceChange callback
with real-time UI
- Add current sequence display with color-coded feedback
- Use onSequenceChange for real-time progress updates
- Update parameter names to match new API
- Clear sequence when switching targets
---
packages/morse_tap/example/lib/main.dart | 33 +++++++++++++++++--
packages/morse_tap/example/main.dart | 40 ++++++++++++++++++++----
2 files changed, 65 insertions(+), 8 deletions(-)
diff --git a/packages/morse_tap/example/lib/main.dart b/packages/morse_tap/example/lib/main.dart
index 371ca66..f5daf40 100644
--- a/packages/morse_tap/example/lib/main.dart
+++ b/packages/morse_tap/example/lib/main.dart
@@ -104,6 +104,7 @@ class _MorseTapDetectorExampleState extends State {
String _currentTarget = 'SOS';
Color _buttonColor = Colors.blue;
String _gestureHint = '';
+ String _currentSequence = '';
final Map _targets = {
'SOS': '... --- ...',
@@ -192,6 +193,13 @@ class _MorseTapDetectorExampleState extends State {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
+ MorseTapDetector(
+ expectedMorseCode: '...',
+ onCorrectSequence: () {},
+ child: CircleAvatar(),
+ ),
+ const SizedBox(height: 16),
+
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
@@ -221,6 +229,22 @@ class _MorseTapDetectorExampleState extends State {
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,
+ ),
+ ),
+ ],
],
),
),
@@ -247,6 +271,7 @@ class _MorseTapDetectorExampleState extends State {
'Use gestures to input $target (${_targets[target]})';
_buttonColor = Colors.blue;
_gestureHint = '';
+ _currentSequence = '';
});
}
},
@@ -264,11 +289,15 @@ class _MorseTapDetectorExampleState extends State {
expectedMorseCode: _targets[_currentTarget]!,
onCorrectSequence: _onCorrectSequence,
onIncorrectSequence: _onIncorrectSequence,
- onSequenceTimeout: _onTimeout,
+ onInputTimeout: _onTimeout,
+ onSequenceChange: (sequence) {
+ setState(() {
+ _currentSequence = sequence;
+ });
+ },
onDotAdded: _onDotAdded,
onDashAdded: _onDashAdded,
onSpaceAdded: _onSpaceAdded,
- feedbackColor: _buttonColor,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: BoxDecoration(
diff --git a/packages/morse_tap/example/main.dart b/packages/morse_tap/example/main.dart
index 07153fb..6b1fc49 100644
--- a/packages/morse_tap/example/main.dart
+++ b/packages/morse_tap/example/main.dart
@@ -104,6 +104,7 @@ class _MorseTapDetectorExampleState extends State {
String _currentTarget = 'SOS';
Color _buttonColor = Colors.blue;
String _gestureHint = '';
+ String _currentSequence = '';
final Map _targets = {
'SOS': '... --- ...',
@@ -123,7 +124,8 @@ class _MorseTapDetectorExampleState extends State {
Future.delayed(const Duration(seconds: 2), () {
if (mounted) {
setState(() {
- _message = 'Use gestures to input $_currentTarget (${_targets[_currentTarget]})';
+ _message =
+ 'Use gestures to input $_currentTarget (${_targets[_currentTarget]})';
_buttonColor = Colors.blue;
});
}
@@ -140,7 +142,8 @@ class _MorseTapDetectorExampleState extends State {
Future.delayed(const Duration(seconds: 1), () {
if (mounted) {
setState(() {
- _message = 'Use gestures to input $_currentTarget (${_targets[_currentTarget]})';
+ _message =
+ 'Use gestures to input $_currentTarget (${_targets[_currentTarget]})';
_buttonColor = Colors.blue;
});
}
@@ -157,7 +160,8 @@ class _MorseTapDetectorExampleState extends State {
Future.delayed(const Duration(seconds: 1), () {
if (mounted) {
setState(() {
- _message = 'Use gestures to input $_currentTarget (${_targets[_currentTarget]})';
+ _message =
+ 'Use gestures to input $_currentTarget (${_targets[_currentTarget]})';
_buttonColor = Colors.blue;
});
}
@@ -218,6 +222,22 @@ class _MorseTapDetectorExampleState extends State {
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,
+ ),
+ ),
+ ],
],
),
),
@@ -244,6 +264,7 @@ class _MorseTapDetectorExampleState extends State {
'Use gestures to input $target (${_targets[target]})';
_buttonColor = Colors.blue;
_gestureHint = '';
+ _currentSequence = '';
});
}
},
@@ -261,11 +282,15 @@ class _MorseTapDetectorExampleState extends State {
expectedMorseCode: _targets[_currentTarget]!,
onCorrectSequence: _onCorrectSequence,
onIncorrectSequence: _onIncorrectSequence,
- onSequenceTimeout: _onTimeout,
+ onInputTimeout: _onTimeout,
+ onSequenceChange: (sequence) {
+ setState(() {
+ _currentSequence = sequence;
+ });
+ },
onDotAdded: _onDotAdded,
onDashAdded: _onDashAdded,
onSpaceAdded: _onSpaceAdded,
- feedbackColor: _buttonColor,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: BoxDecoration(
@@ -324,7 +349,10 @@ class _MorseTapDetectorExampleState extends State {
if (_gestureHint.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 4,
+ ),
decoration: BoxDecoration(
color: Colors.blue[50],
border: Border.all(color: Colors.blue[200]!),
From 25483b5baa9aa611f85290a7f654bbe31b588505 Mon Sep 17 00:00:00 2001
From: Ajay Kumar
Date: Fri, 22 Aug 2025 18:06:48 +0530
Subject: [PATCH 04/18] docs(morse_tap): add screenshot and improve
documentation
- Add morse_tap.png screenshot to package
- Include screenshot in README and pubspec.yaml
- Fix incorrect callback names in README examples
- Update package description and repository metadata
- Document onSequenceChange callback usage
---
packages/morse_tap/README.md | 24 +++++++++++++++---------
packages/morse_tap/morse_tap.png | Bin 0 -> 292864 bytes
packages/morse_tap/pubspec.yaml | 9 +++++++--
3 files changed, 22 insertions(+), 11 deletions(-)
create mode 100644 packages/morse_tap/morse_tap.png
diff --git a/packages/morse_tap/README.md b/packages/morse_tap/README.md
index f246d0b..de15ca3 100644
--- a/packages/morse_tap/README.md
+++ b/packages/morse_tap/README.md
@@ -17,6 +17,8 @@
A Flutter package that provides Morse code input functionality using intuitive gestures. Create interactive Morse code experiences with single taps for dots, double taps for dashes, and long presses for spaces.
+
+
## Features
✨ **MorseTapDetector** - Widget that detects specific Morse code patterns using gestures
@@ -69,6 +71,10 @@ MorseTapDetector(
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"),
@@ -153,20 +159,20 @@ print(isMorseInput); // false
### Timing Configuration
-Customize tap timing thresholds:
+Customize the input timeout:
```dart
MorseTapDetector(
- expectedMorseCode: "...",
- dotThreshold: Duration(milliseconds: 150), // Shorter for dots
- dashThreshold: Duration(milliseconds: 400), // Longer for dashes
- letterGap: Duration(milliseconds: 600), // Gap between letters
- sequenceTimeout: Duration(seconds: 5), // Reset timeout
- onTap: () => print("Correct!"),
+ 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.
+
### Visual Feedback
Control visual feedback options:
@@ -214,7 +220,7 @@ final customPattern = "HELP".toMorseCode();
MorseTapDetector(
expectedMorseCode: customPattern,
- onTap: () => showDialog(
+ onCorrectSequence: () => showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text("Help Requested!"),
@@ -243,7 +249,7 @@ class MultiPatternDetector extends StatelessWidget {
children: patterns.entries.map((entry) {
return MorseTapDetector(
expectedMorseCode: entry.value,
- onTap: () => handlePattern(entry.key),
+ onCorrectSequence: () => handlePattern(entry.key),
child: PatternButton(label: entry.key),
);
}).toList(),
diff --git a/packages/morse_tap/morse_tap.png b/packages/morse_tap/morse_tap.png
new file mode 100644
index 0000000000000000000000000000000000000000..7bc1278f8670054048c515574e55ddafc7f520ac
GIT binary patch
literal 292864
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