diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index b4fe0336..2c446d56 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -7,6 +7,10 @@ assignees: '' --- +## Welcome to Our Project! ๐Ÿ‘‹ + +Thank you for reporting this bug! Your help in identifying issues is greatly appreciated and helps improve the project for everyone. ๐Ÿ› + **Describe the bug** A clear and concise description of what the bug is. diff --git a/.github/ISSUE_TEMPLATE/community-discussion.md b/.github/ISSUE_TEMPLATE/community-discussion.md new file mode 100644 index 00000000..0bf04f27 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/community-discussion.md @@ -0,0 +1,27 @@ +--- +name: Community Discussion +about: Start a discussion about the project, ask questions, or share ideas +title: Discussion +labels: discussion, community +assignees: '' + +--- + +## Welcome to Our Project! ๐Ÿ‘‹ + +We're excited to hear your thoughts and engage in discussion! This space is for community conversations, questions, and ideas that don't fit into bug reports or feature requests. ๐Ÿ’ฌ + +**Discussion Topic:** +What would you like to discuss? (e.g., project direction, best practices, collaboration ideas, general questions) + +**Context:** +Provide any relevant background or context for your discussion. + +**Your Thoughts:** +Share your ideas, questions, or suggestions here. + +--- + +*Remember: This is a welcoming community! Please be respectful and constructive in your discussions. Check our [Code of Conduct](https://github.com/may-tas/TextEditingApp/blob/main/CODE_OF_CONDUCT.md) for community guidelines.* + +Thank you for being part of our journey! ๐ŸŒŸ \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..0c4be99c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,17 @@ +# Issue Template Configuration +# This file controls the order and visibility of issue templates + +blank_issues_enabled: true +contact_links: + - name: ๐Ÿ“– Documentation + url: https://github.com/may-tas/TextEditingApp/blob/main/README.md + about: Check our documentation for help and guides + - name: ๐Ÿค Contributing Guide + url: https://github.com/may-tas/TextEditingApp/blob/main/CONTRIBUTING.md + about: Learn how to contribute to the project + - name: ๐Ÿ’ฌ Community Discussions + url: https://github.com/may-tas/TextEditingApp/discussions + about: Start a discussion or ask questions + - name: ๐ŸŽฏ Project Board + url: https://github.com/may-tas/TextEditingApp/projects + about: View our project roadmap and current work \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md index 4e05e11d..a33de7ec 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -7,6 +7,10 @@ assignees: '' --- +## Welcome to Our Project! ๐Ÿ‘‹ + +Thank you for taking the time to suggest a feature! Your ideas help make this project better. ๐Ÿš€ + **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. diff --git a/.github/ISSUE_TEMPLATE/welcome-community.md b/.github/ISSUE_TEMPLATE/welcome-community.md new file mode 100644 index 00000000..cdaca3d3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/welcome-community.md @@ -0,0 +1,48 @@ +--- +name: Welcome & Community Discussion +about: Welcome to our project! Share ideas, report bugs, or discuss contributions +title: Welcome to Our Project! Share Your Ideas, Report Bugs, and Contribute ๐Ÿš€ +labels: welcome, community +assignees: '' + +--- + +## Welcome to Our Project! ๐Ÿ‘‹ + +We're thrilled to have you here and would love to hear your thoughts, ideas, and feedback. This issue is a space to: + +### Share Your Ideas ๐Ÿ’ก +Have a feature in mind? Let us know what you'd like to see in this project. + +### Report Bugs ๐Ÿ› +Encountered an issue while using the project? Open a new issue with details so we can resolve it. + +### Contribute ๐Ÿค +Found an area to improve? Feel free to fork the repo, make changes, and submit a pull request (PR). + +## How You Can Help: + +- ๐Ÿ“– Check the [README.md](https://github.com/may-tas/TextEditingApp/blob/main/README.md) for project details and guidelines +- ๐Ÿ› Open a new issue to share ideas or report bugs +- ๐Ÿš€ Follow our [CONTRIBUTING.md](https://github.com/may-tas/TextEditingApp/blob/main/CONTRIBUTING.md) guidelines to submit a PR +- ๐Ÿ’ฌ Read our [WELCOME.md](https://github.com/may-tas/TextEditingApp/blob/main/WELCOME.md) for comprehensive getting started guide + +## Your contributions make this project better! Let's collaborate and build something amazing together. ๐Ÿ’กโœจ + +--- + +**What would you like to discuss?** (Please delete this template text and replace with your content) + +**Type of Discussion:** +- [ ] Feature Idea +- [ ] Bug Report +- [ ] General Discussion +- [ ] Contribution Question +- [ ] Other + +**Description:** + +**Additional Context:** +Add any other context, screenshots, or details here. + +Thank you for being part of this journey! ๐ŸŽ‰ \ No newline at end of file diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml index a71e67ac..10c082dc 100644 --- a/.github/workflows/issues.yml +++ b/.github/workflows/issues.yml @@ -3,7 +3,7 @@ # # NOTE! # -# Please read the README.md file in this directory that defines what should +# Please read the README.md file in this directory that defines what should # be placed in this file # ############################################################################## @@ -17,9 +17,28 @@ on: permissions: issues: write pull-requests: write - + jobs: - Opened-issue-label: + first-issue-greeting: + name: First Issue Greeting + runs-on: ubuntu-latest + steps: + - uses: actions/first-interaction@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + issue-message: | + ## ๐ŸŽ‰ Congratulations on opening your first issue! + + Welcome to the TextEditingApp community! We're excited to have you here. This is a great first step in contributing to our project. + + ### Getting Started: + - Check out our [Contributing Guidelines](https://github.com/may-tas/TextEditingApp/blob/main/CONTRIBUTING.md) + - Read our [README](https://github.com/may-tas/TextEditingApp/blob/main/README.md) to understand the project + - Join our community discussions + + Don't hesitate to ask questions - we're here to help! ๐Ÿ’ช + + opened-issue-label: name: Adding Issue Label runs-on: ubuntu-latest steps: @@ -32,8 +51,8 @@ jobs: repo: context.repo.repo, labels: ['unapproved'] }) - - Issue-Greeting: + + issue-greeting: name: Greeting Message to User runs-on: ubuntu-latest steps: @@ -44,5 +63,5 @@ jobs: issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: "Thank you for opening this issue! ๐Ÿš€ Our team will review it soon. Please make sure you've included all relevant details and followed our [Contributing Guidelines](https://github.com/may-tas/TextEditingApp/blob/main/CONTRIBUTING.md). For questions about our project, check out our [README](https://github.com/may-tas/TextEditingApp/blob/main/README.md) and [Code of Conduct](https://github.com/may-tas/TextEditingApp/blob/main/CODE_OF_CONDUCT.md)." + body: "## Welcome to Our Project! ๐Ÿ‘‹\n\nThank you for opening this issue! We're thrilled to have you here and would love to hear your thoughts, ideas, and feedback. ๐Ÿš€\n\n### What happens next:\n- Our team will review your issue soon\n- We'll add appropriate labels and assign reviewers\n- For bugs: Please include steps to reproduce, expected vs actual behavior\n- For features: Share your use case and why this would be valuable\n\n### Quick Links:\n- ๐Ÿ“– [README](https://github.com/may-tas/TextEditingApp/blob/main/README.md) - Project overview and setup\n- ๐Ÿค [Contributing Guidelines](https://github.com/may-tas/TextEditingApp/blob/main/CONTRIBUTING.md) - How to contribute\n- ๐Ÿ“‹ [Code of Conduct](https://github.com/may-tas/TextEditingApp/blob/main/CODE_OF_CONDUCT.md) - Community standards\n\nYour contributions make this project better! Let's collaborate and build something amazing together. ๐Ÿ’กโœจ" }) \ No newline at end of file diff --git a/README.md b/README.md index 0bc450ea..0bdb13ce 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,25 @@ [![All Contributors](https://img.shields.io/badge/all_contributors-16-orange.svg?style=flat-square)](#contributors-) +## Welcome to Our Project! ๐Ÿ‘‹ + +We're thrilled to have you here and would love to hear your thoughts, ideas, and feedback. This project is a space to: + +- **Share Your Ideas**: Have a feature in mind? Let us know what you'd like to see in this project. +- **Report Bugs**: Encountered an issue while using the project? Open a new issue with details so we can resolve it. +- **Contribute**: Found an area to improve? Feel free to fork the repo, make changes, and submit a pull request (PR). + +### How You Can Help: +- Check this README.md for project details and guidelines. +- Read our [WELCOME.md](WELCOME.md) for a comprehensive introduction to contributing. +- Use our [issue templates](.github/ISSUE_TEMPLATE/) to open discussions, report bugs, or suggest features. +- Open a new issue to share ideas or report bugs. +- Follow our [CONTRIBUTING.md](CONTRIBUTING.md) guidelines to submit a PR. + +Your contributions make this project better! Let's collaborate and build something amazing together. ๐Ÿ’กโœจ + +--- + This project provides an interactive user interface to control font properties such as font size, font family, font color, and font style. Built using Flutter and Bloc for state management, it ensures a modern, responsive, and smooth user experience. ## Features @@ -35,6 +54,28 @@ This project provides an interactive user interface to control font properties s | Web Web | โœ… Supported | Modern Browsers | +## Community & Contributing + +We believe in the power of community-driven development! Our project welcomes contributors of all skill levels and backgrounds. Whether you're a seasoned developer or just getting started, there's a place for you here. + +### Ways to Get Involved: +- ๐Ÿ› **Report Bugs**: Help us improve by reporting issues you encounter +- ๐Ÿ’ก **Suggest Features**: Share your ideas for new functionality +- ๐Ÿ’ฌ **Start Discussions**: Engage with the community on project direction +- ๐Ÿš€ **Contribute Code**: Submit pull requests to add features or fix bugs +- ๐Ÿ“– **Improve Documentation**: Help make our docs clearer and more comprehensive + +### Getting Started: +1. Read our [WELCOME.md](WELCOME.md) for a comprehensive introduction +2. Check out our [Contributing Guidelines](CONTRIBUTING.md) +3. Use our [issue templates](.github/ISSUE_TEMPLATE/) to report bugs or suggest features +4. Join the conversation in our [GitHub Discussions](https://github.com/may-tas/TextEditingApp/discussions) + +### Recognition: +We use the [All Contributors](https://github.com/all-contributors/all-contributors) specification to recognize all contributors. Every contribution counts! โญ + +--- + ## How to Run 1. Clone the repository: diff --git a/WELCOME.md b/WELCOME.md new file mode 100644 index 00000000..52932fa1 --- /dev/null +++ b/WELCOME.md @@ -0,0 +1,84 @@ +# Welcome to TextEditingApp! ๐Ÿ‘‹ + +## About Our Project + +TextEditingApp (TexTerra) is an interactive Flutter application that provides a modern, responsive user interface for controlling font properties. Built with Flutter and Bloc for state management, it offers a smooth user experience across multiple platforms including Android, iOS, Web, Linux, macOS, and Windows. + +## ๐Ÿš€ Getting Started + +### For Contributors +1. **Read our documentation**: + - ๐Ÿ“– [README.md](README.md) - Project overview and setup instructions + - ๐Ÿค [CONTRIBUTING.md](CONTRIBUTING.md) - Detailed contribution guidelines + - ๐Ÿ“‹ [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) - Community standards + +2. **Set up your development environment**: + ```bash + git clone https://github.com/may-tas/TextEditingApp.git + cd TextEditingApp + flutter pub get + flutter run + ``` + +3. **Explore the codebase**: + - `lib/` - Main application code + - `lib/cubit/` - State management (Bloc pattern) + - `lib/models/` - Data models + - `lib/ui/` - User interface components + - `test/` - Unit and widget tests + +## ๐Ÿค How to Contribute + +### Types of Contributions +- **๐Ÿ› Bug Reports**: Found an issue? [Open a bug report](https://github.com/may-tas/TextEditingApp/issues/new?template=bug_report.md) +- **๐Ÿ’ก Feature Requests**: Have an idea? [Share your suggestion](https://github.com/may-tas/TextEditingApp/issues/new?template=feature_request.md) +- **๐Ÿ“ Documentation**: Help improve our docs +- **๐Ÿงช Testing**: Add or improve tests +- **๐Ÿ’ป Code**: Submit pull requests with fixes or features + +### Contribution Process +1. **Fork** the repository +2. **Create** a feature branch (`git checkout -b feature/amazing-feature`) +3. **Commit** your changes (`git commit -m 'Add amazing feature'`) +4. **Push** to the branch (`git push origin feature/amazing-feature`) +5. **Open** a Pull Request + +### Guidelines +- Follow our [Contributing Guidelines](CONTRIBUTING.md) +- Write clear, descriptive commit messages +- Add tests for new features +- Update documentation as needed +- Ensure all tests pass + +## ๐ŸŽฏ Current Focus Areas + +We're actively looking for contributions in these areas: +- **Performance Optimization**: Improve rendering performance for large canvases +- **Testing**: Expand our test coverage +- **Accessibility**: Enhance support for screen readers and keyboard navigation +- **New Features**: Drawing tools, collaborative editing, themes +- **Platform Support**: Web PWA features, iOS-specific enhancements + +## ๐Ÿ“ž Getting Help + +- **Issues**: Use [GitHub Issues](https://github.com/may-tas/TextEditingApp/issues) for bugs and features +- **Discussions**: Join community discussions for questions and ideas +- **Documentation**: Check our [README](README.md) and [Contributing Guide](CONTRIBUTING.md) + +## ๐Ÿ™ Recognition + +We use the [All Contributors](https://github.com/all-contributors/all-contributors) specification to recognize all contributors. Your contributions, no matter how small, are valued and appreciated! + +## ๐Ÿ“ˆ Project Stats + +- โญ **16 contributors** and growing! +- ๐ŸŒ **Multi-platform** support (Android, iOS, Web, Desktop) +- ๐ŸŽจ **Modern UI** with Material Design +- ๐Ÿ”ง **Bloc state management** for predictable app behavior + +--- + +**Thank you for being part of our journey!** Let's build something amazing together. ๐Ÿ’กโœจ + +*For questions about contributing, please check our [Contributing Guidelines](CONTRIBUTING.md) or open a [discussion](https://github.com/may-tas/TextEditingApp/discussions).* +c:/Users/Gupta/Downloads/TextEditingApp/WELCOME.md \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index d93fb042..ab0a3a53 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,8 +3,16 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'cubit/canvas_cubit.dart'; import 'ui/screens/splash_screen.dart'; import 'utils/custom_snackbar.dart'; +import 'utils/web_utils.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize web utilities if running on web + if (WebUtils.isWeb) { + await WebUtils().initialize(); + } -void main() { runApp(const MyApp()); } diff --git a/lib/ui/screens/canvas_screen.dart b/lib/ui/screens/canvas_screen.dart index 0e08d468..93421b6f 100644 --- a/lib/ui/screens/canvas_screen.dart +++ b/lib/ui/screens/canvas_screen.dart @@ -15,6 +15,8 @@ import '../widgets/background_color_tray.dart'; import '../widgets/background_options_sheet.dart'; import '../widgets/drawing_canvas.dart'; import '../../utils/custom_snackbar.dart'; +import '../../utils/web_utils.dart'; +import '../widgets/web_widgets.dart'; class CanvasScreen extends StatelessWidget { const CanvasScreen({super.key}); @@ -31,323 +33,429 @@ class CanvasScreen extends StatelessWidget { ); } + // Handle dropped files + void _handleFileDrop(BuildContext context, List files) { + final cubit = context.read(); + + for (final file in files) { + if (file.bytes != null) { + // Handle image files + if (WebUtils.isImageFile(file.name)) { + cubit.setBackgroundImageFromBytes(file.bytes!, file.name); + CustomSnackbar.showSuccess('Background image set from ${file.name}'); + } + // Handle text files + else if (WebUtils.isTextFile(file.name)) { + final text = utf8.decode(file.bytes!); + cubit.addTextItem( + TextItemModel( + text: text, + x: 50, + y: 50, + fontSize: 16, + fontFamily: 'Arial', + color: Colors.black, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + textAlign: TextAlign.left, + ), + ); + CustomSnackbar.showSuccess('Text loaded from ${file.name}'); + } + // Handle other file types + else { + CustomSnackbar.showInfo('File type not supported: ${file.name}'); + } + } + } + } + + // Handle keyboard shortcuts + void _handleKeyboardShortcut(String shortcut, BuildContext context) { + final cubit = context.read(); + + switch (shortcut) { + case 'ctrl+n': + case 'cmd+n': + cubit.createNewPage(); + CustomSnackbar.showInfo('New page created'); + break; + case 'ctrl+z': + case 'cmd+z': + if (cubit.state.history.isNotEmpty) { + cubit.undo(); + } else { + CustomSnackbar.showInfo('Nothing to undo'); + } + break; + case 'ctrl+y': + case 'cmd+y': + case 'ctrl+shift+z': + case 'cmd+shift+z': + if (cubit.state.future.isNotEmpty) { + cubit.redo(); + } else { + CustomSnackbar.showInfo('Nothing to redo'); + } + break; + case 'ctrl+s': + case 'cmd+s': + cubit.handleSaveAction(); + break; + case 'delete': + case 'backspace': + if (cubit.state.textItems.isNotEmpty || cubit.state.backgroundImagePath != null) { + cubit.clearCanvas(); + } else if (cubit.state.drawPaths.isNotEmpty) { + cubit.clearDrawings(); + } else { + CustomSnackbar.showInfo('Canvas is already empty'); + } + break; + case 'ctrl+t': + case 'cmd+t': + if (cubit.state.isDrawingMode) { + cubit.setDrawingMode(false); + } + cubit.addText('New Text'); + break; + case 'ctrl+d': + case 'cmd+d': + cubit.toggleDrawingMode(); + break; + case 'escape': + if (cubit.state.isDrawingMode) { + cubit.setDrawingMode(false); + } else { + cubit.deselectText(); + } + break; + } + } + @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: ColorConstants.uiWhite, - appBar: AppBar( - backgroundColor: ColorConstants.uiWhite, - elevation: 0.5, - title: BlocBuilder( - builder: (context, state) { - return Column( - children: [ - const Text( - 'Text Editor', - style: TextStyle( - color: ColorConstants.dialogTextBlack, - fontWeight: FontWeight.w600, - fontSize: 20, + return PWAInstallPrompt( + child: NetworkStatusIndicator( + child: WebKeyboardShortcuts( + onShortcut: (shortcut) => _handleKeyboardShortcut(shortcut, context), + child: DragDropOverlay( + onFilesDropped: (files) => _handleFileDrop(context, files), + child: Scaffold( + backgroundColor: ColorConstants.uiWhite, + appBar: AppBar( + backgroundColor: ColorConstants.uiWhite, + elevation: 0.5, + title: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + const Text( + 'Text Editor', + style: TextStyle( + color: ColorConstants.dialogTextBlack, + fontWeight: FontWeight.w600, + fontSize: 20, + ), + ), + if (state.currentPageName != null) + Text( + state.currentPageName!, + style: const TextStyle( + color: ColorConstants.uiGrayMedium, + fontSize: 12, + fontWeight: FontWeight.normal, + ), + ), + ], + ); + }, + ), + centerTitle: true, + leading: IconButton( + tooltip: "Clear Canvas", + icon: const Icon(Icons.delete, color: ColorConstants.uiIconBlack), + onPressed: () { + final cubit = context.read(); + if (cubit.state.textItems.isNotEmpty || + cubit.state.backgroundImagePath != null) { + cubit.clearCanvas(); + } else if (cubit.state.drawPaths.isNotEmpty) { + cubit.clearDrawings(); + } else { + // Show info when canvas is already empty + CustomSnackbar.showInfo('Canvas is already empty'); + } + }, + ), + actions: [ + // Background options button + IconButton( + tooltip: 'Background options', + icon: const Icon( + Icons.wallpaper, + color: ColorConstants.uiIconBlack, ), + onPressed: () => _showBackgroundOptions(context), ), - if (state.currentPageName != null) - Text( - state.currentPageName!, - style: const TextStyle( - color: ColorConstants.uiGrayMedium, - fontSize: 12, - fontWeight: FontWeight.normal, - ), - ), - ], - ); - }, - ), - centerTitle: true, - leading: IconButton( - tooltip: "Clear Canvas", - icon: const Icon(Icons.delete, color: ColorConstants.uiIconBlack), - onPressed: () { - final cubit = context.read(); - if (cubit.state.textItems.isNotEmpty || - cubit.state.backgroundImagePath != null) { - cubit.clearCanvas(); - } else if (cubit.state.drawPaths.isNotEmpty) { - cubit.clearDrawings(); - } else { - // Show info when canvas is already empty - CustomSnackbar.showInfo('Canvas is already empty'); - } - }, - ), - actions: [ - // Background options button - IconButton( - tooltip: 'Background options', - icon: const Icon( - Icons.wallpaper, - color: ColorConstants.uiIconBlack, - ), - onPressed: () => _showBackgroundOptions(context), - ), - // Undo button - IconButton( - tooltip: "Undo", - icon: const Icon(Icons.undo, color: ColorConstants.uiIconBlack), - onPressed: () { - final cubit = context.read(); - if (cubit.state.history.isNotEmpty) { - cubit.undo(); - } else { - CustomSnackbar.showInfo('Nothing to undo'); - } - }, - ), - // Redo button - IconButton( - tooltip: "Redo", - icon: const Icon(Icons.redo, color: ColorConstants.uiIconBlack), - onPressed: () { - final cubit = context.read(); - if (cubit.state.future.isNotEmpty) { - cubit.redo(); - } else { - CustomSnackbar.showInfo('Nothing to redo'); - } - }, - ), - // More options dropdown menu - BlocBuilder( - builder: (context, state) { - return PopupMenuButton( - tooltip: "More options", - icon: const Icon(Icons.more_vert, - color: ColorConstants.uiIconBlack), - color: ColorConstants.uiWhite, // White background for dropdown - onSelected: (value) async { - final cubit = context.read(); + // Undo button + IconButton( + tooltip: "Undo", + icon: const Icon(Icons.undo, color: ColorConstants.uiIconBlack), + onPressed: () { + final cubit = context.read(); + if (cubit.state.history.isNotEmpty) { + cubit.undo(); + } else { + CustomSnackbar.showInfo('Nothing to undo'); + } + }, + ), + // Redo button + IconButton( + tooltip: "Redo", + icon: const Icon(Icons.redo, color: ColorConstants.uiIconBlack), + onPressed: () { + final cubit = context.read(); + if (cubit.state.future.isNotEmpty) { + cubit.redo(); + } else { + CustomSnackbar.showInfo('Nothing to redo'); + } + }, + ), + // More options dropdown menu + BlocBuilder( + builder: (context, state) { + return PopupMenuButton( + tooltip: "More options", + icon: const Icon(Icons.more_vert, + color: ColorConstants.uiIconBlack), + color: ColorConstants.uiWhite, // White background for dropdown + onSelected: (value) async { + final cubit = context.read(); - switch (value) { - case 'new_page': - cubit.createNewPage(); - break; - case 'load_pages': - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => BlocProvider.value( - value: context.read(), - child: const SavedPagesScreen(), + switch (value) { + case 'new_page': + cubit.createNewPage(); + break; + case 'load_pages': + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: context.read(), + child: const SavedPagesScreen(), + ), + ), + ); + break; + case 'save_page': + final wasHandled = await cubit.handleSaveAction(); + if (!wasHandled) { + if (!context.mounted) return; + showDialog( + context: context, + builder: (context) => BlocProvider.value( + value: cubit, + child: const SavePageDialog(), + ), + ); + } + break; + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: 'new_page', + child: Row( + children: [ + Icon(Icons.add, + color: ColorConstants.uiIconBlack, size: 20), + SizedBox(width: 12), + Text( + 'New Page', + style: TextStyle( + color: ColorConstants.dialogTextBlack87), + ), + ], ), ), - ); - break; - case 'save_page': - final wasHandled = await cubit.handleSaveAction(); - if (!wasHandled) { - if (!context.mounted) return; - showDialog( - context: context, - builder: (context) => BlocProvider.value( - value: cubit, - child: const SavePageDialog(), + const PopupMenuItem( + value: 'load_pages', + child: Row( + children: [ + Icon(Icons.folder_open, + color: ColorConstants.uiIconBlack, size: 20), + SizedBox(width: 12), + Text( + 'Saved Pages', + style: TextStyle( + color: ColorConstants.dialogTextBlack87), + ), + ], ), - ); - } - break; - } - }, - itemBuilder: (BuildContext context) => >[ - const PopupMenuItem( - value: 'new_page', - child: Row( - children: [ - Icon(Icons.add, - color: ColorConstants.uiIconBlack, size: 20), - SizedBox(width: 12), - Text( - 'New Page', - style: TextStyle( - color: ColorConstants.dialogTextBlack87), ), - ], - ), - ), - const PopupMenuItem( - value: 'load_pages', - child: Row( - children: [ - Icon(Icons.folder_open, - color: ColorConstants.uiIconBlack, size: 20), - SizedBox(width: 12), - Text( - 'Saved Pages', - style: TextStyle( - color: ColorConstants.dialogTextBlack87), + PopupMenuItem( + value: 'save_page', + child: Row( + children: [ + Icon( + state.currentPageName != null + ? Icons.save + : Icons.save_as, + color: ColorConstants.uiIconBlack, + size: 20, + ), + const SizedBox(width: 12), + Text( + state.currentPageName != null + ? "Save '${state.currentPageName}'" + : "Save Page", + style: const TextStyle( + color: ColorConstants.dialogTextBlack87), + ), + ], + ), ), ], - ), - ), - PopupMenuItem( - value: 'save_page', - child: Row( + ); + }, + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + return GestureDetector( + onTap: () { + // Only deselect if we're not in drawing mode + if (!state.isDrawingMode) { + context.read().deselectText(); + } + }, + behavior: HitTestBehavior.deferToChild, + child: Container( + decoration: _buildBackgroundDecoration(state), + child: Stack( children: [ - Icon( - state.currentPageName != null - ? Icons.save - : Icons.save_as, - color: ColorConstants.uiIconBlack, - size: 20, - ), - const SizedBox(width: 12), - Text( - state.currentPageName != null - ? "Save '${state.currentPageName}'" - : "Save Page", - style: const TextStyle( - color: ColorConstants.dialogTextBlack87), + // Drawing Canvas + DrawingCanvas( + paths: state.drawPaths, + isDrawingMode: state.isDrawingMode, + currentDrawColor: state.currentDrawColor, + currentStrokeWidth: state.currentStrokeWidth, + onStartDrawing: (offset) { + context.read().startNewDrawPath(offset); + }, + onUpdateDrawing: (offset) { + context.read().updateDrawPath(offset); + }, + onEndDrawing: () { + // Nothing needed here for now + }, + onColorChanged: (color) { + context.read().setDrawColor(color); + }, + onStrokeWidthChanged: (width) { + context.read().setStrokeWidth(width); + }, + onUndoDrawing: () { + context.read().undoLastDrawing(); + }, + onClearDrawing: () { + context.read().clearDrawings(); + }, ), + + // Text Items + for (int index = 0; index < state.textItems.length; index++) + Positioned( + left: state.textItems[index].x, + top: state.textItems[index].y, + child: IgnorePointer( + ignoring: state.isDrawingMode, + child: _DraggableText( + key: ValueKey('text_item_$index'), + index: index, + textItem: state.textItems[index], + isSelected: !state.isDrawingMode && + state.selectedTextItemIndex == index, + ), + ), + ), + + // Drawing Mode Indicator + if (state.isDrawingMode) + Positioned( + top: 16, + right: 16, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: ColorConstants.getBlackWithValues(alpha: 0.7), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.brush, + color: state.currentDrawColor, size: 16), + const SizedBox(width: 8), + const Text( + 'Drawing Mode', + style: TextStyle( + color: ColorConstants.dialogWhite, + fontSize: 12), + ), + ], + ), + ), + ), ], ), ), - ], - ); - }, - ), - ], - ), - body: BlocBuilder( - builder: (context, state) { - return GestureDetector( - onTap: () { - // Only deselect if we're not in drawing mode - if (!state.isDrawingMode) { - context.read().deselectText(); - } - }, - behavior: HitTestBehavior.deferToChild, - child: Container( - decoration: _buildBackgroundDecoration(state), - child: Stack( - children: [ - // Drawing Canvas - DrawingCanvas( - paths: state.drawPaths, - isDrawingMode: state.isDrawingMode, - currentDrawColor: state.currentDrawColor, - currentStrokeWidth: state.currentStrokeWidth, - onStartDrawing: (offset) { - context.read().startNewDrawPath(offset); - }, - onUpdateDrawing: (offset) { - context.read().updateDrawPath(offset); - }, - onEndDrawing: () { - // Nothing needed here for now - }, - onColorChanged: (color) { - context.read().setDrawColor(color); - }, - onStrokeWidthChanged: (width) { - context.read().setStrokeWidth(width); - }, - onUndoDrawing: () { - context.read().undoLastDrawing(); - }, - onClearDrawing: () { - context.read().clearDrawings(); - }, - ), - - // Text Items - for (int index = 0; index < state.textItems.length; index++) - Positioned( - left: state.textItems[index].x, - top: state.textItems[index].y, - child: IgnorePointer( - ignoring: state.isDrawingMode, - child: _DraggableText( - key: ValueKey('text_item_$index'), - index: index, - textItem: state.textItems[index], - isSelected: !state.isDrawingMode && - state.selectedTextItemIndex == index, - ), + ); + }, + ), + extendBody: true, + bottomNavigationBar: BlocBuilder( + builder: (context, state) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: ColorConstants.getBlackWithAlpha((0.05 * 255).toInt()), + blurRadius: 10, + offset: const Offset(0, -5), ), - ), - - // Drawing Mode Indicator - if (state.isDrawingMode) - Positioned( - top: 16, - right: 16, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: ColorConstants.getBlackWithValues(alpha: 0.7), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.brush, - color: state.currentDrawColor, size: 16), - const SizedBox(width: 8), - const Text( - 'Drawing Mode', - style: TextStyle( - color: ColorConstants.dialogWhite, - fontSize: 12), - ), - ], + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Visibility( + visible: state.isTrayShown, + child: Container( + color: ColorConstants.dialogWhite, + child: const BackgroundColorTray(), ), ), - ), - ], - ), - ), - ); - }, - ), - extendBody: true, - bottomNavigationBar: BlocBuilder( - builder: (context, state) { - return Container( - margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: ColorConstants.getBlackWithAlpha((0.05 * 255).toInt()), - blurRadius: 10, - offset: const Offset(0, -5), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Visibility( - visible: state.isTrayShown, - child: Container( - color: ColorConstants.dialogWhite, - child: const BackgroundColorTray(), + const FontControls(), + ], ), - ), - const FontControls(), - ], - ), - ); - }, - ), - floatingActionButton: BlocBuilder( - builder: (context, state) { - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), + ); + }, ), + floatingActionButton: BlocBuilder( + builder: (context, state) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + ), child: FloatingActionButton( backgroundColor: ColorConstants.dialogWhite, elevation: 0.5, @@ -474,6 +582,10 @@ class CanvasScreen extends StatelessWidget { ), ); }, + ), + ), + ), + ), ), ); } diff --git a/lib/ui/widgets/web_widgets.dart b/lib/ui/widgets/web_widgets.dart new file mode 100644 index 00000000..adb31d09 --- /dev/null +++ b/lib/ui/widgets/web_widgets.dart @@ -0,0 +1,476 @@ +import 'dart:async'; +import 'dart:html' as html; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:texterra/utils/web_utils.dart'; + +/// Drag and drop overlay for web platform +class DragDropOverlay extends StatefulWidget { + final Widget child; + final Function(List files)? onFilesDropped; + final Function(bool isDragging)? onDragStateChanged; + + const DragDropOverlay({ + Key? key, + required this.child, + this.onFilesDropped, + this.onDragStateChanged, + }) : super(key: key); + + @override + State createState() => _DragDropOverlayState(); +} + +class _DragDropOverlayState extends State { + bool _isDragging = false; + StreamSubscription? _dragSubscription; + StreamSubscription? _dropSubscription; + + @override + void initState() { + super.initState(); + if (WebUtils.isWeb) { + _setupWebDragDrop(); + } + } + + @override + void dispose() { + _dragSubscription?.cancel(); + _dropSubscription?.cancel(); + super.dispose(); + } + + void _setupWebDragDrop() { + // Listen for custom events from WebUtils + html.window.addEventListener('files_dropped', (html.Event event) { + final customEvent = event as html.CustomEvent; + final files = customEvent.detail as List?; + + if (files != null && files.isNotEmpty) { + widget.onFilesDropped?.call(files); + } + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + widget.child, + if (_isDragging && WebUtils.isWeb) + Container( + color: Theme.of(context).primaryColor.withOpacity(0.1), + child: Center( + child: Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context).primaryColor, + width: 2, + style: BorderStyle.solid, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + spreadRadius: 5, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.file_upload, + size: 64, + color: Theme.of(context).primaryColor, + ), + const SizedBox(height: 16), + Text( + 'Drop files here', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Supported: Images, Text files, Documents', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ], + ); + } +} + +/// PWA Install Prompt Widget +class PWAInstallPrompt extends StatefulWidget { + final Widget child; + + const PWAInstallPrompt({ + Key? key, + required this.child, + }) : super(key: key); + + @override + State createState() => _PWAInstallPromptState(); +} + +class _PWAInstallPromptState extends State { + bool _showInstallPrompt = false; + StreamSubscription? _installAvailableSubscription; + StreamSubscription? _installedSubscription; + + @override + void initState() { + super.initState(); + if (WebUtils.isWeb && !WebUtils().isPWAInstalled) { + _setupInstallPrompt(); + } + } + + @override + void dispose() { + _installAvailableSubscription?.cancel(); + _installedSubscription?.cancel(); + super.dispose(); + } + + void _setupInstallPrompt() { + // Listen for install availability + html.window.addEventListener('pwa_install_available', (html.Event event) { + if (mounted) { + setState(() => _showInstallPrompt = true); + } + }); + + // Listen for successful installation + html.window.addEventListener('pwa_installed', (html.Event event) { + if (mounted) { + setState(() => _showInstallPrompt = false); + _showSuccessMessage(); + } + }); + } + + void _showSuccessMessage() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('App installed successfully! ๐ŸŽ‰'), + duration: Duration(seconds: 3), + ), + ); + } + + Future _installPWA() async { + final webUtils = WebUtils(); + final accepted = await webUtils.showInstallPrompt(); + + if (accepted) { + setState(() => _showInstallPrompt = false); + } + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + widget.child, + if (_showInstallPrompt) + Positioned( + bottom: 20, + left: 20, + right: 20, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.install_mobile, + color: Theme.of(context).primaryColor, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Install TextEditingApp', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => setState(() => _showInstallPrompt = false), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Install our app for a better experience with offline access and native app features.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => setState(() => _showInstallPrompt = false), + child: const Text('Not now'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _installPWA, + child: const Text('Install'), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ); + } +} + +/// Network Status Indicator +class NetworkStatusIndicator extends StatefulWidget { + final Widget child; + + const NetworkStatusIndicator({ + Key? key, + required this.child, + }) : super(key: key); + + @override + State createState() => _NetworkStatusIndicatorState(); +} + +class _NetworkStatusIndicatorState extends State { + bool _isOnline = true; + StreamSubscription? _networkSubscription; + + @override + void initState() { + super.initState(); + if (WebUtils.isWeb) { + _setupNetworkMonitoring(); + _checkInitialStatus(); + } + } + + @override + void dispose() { + _networkSubscription?.cancel(); + super.dispose(); + } + + void _setupNetworkMonitoring() { + html.window.addEventListener('networkstatuschange', (html.Event event) { + final customEvent = event as html.CustomEvent; + final isOnline = customEvent.detail as bool? ?? true; + + if (mounted && _isOnline != isOnline) { + setState(() => _isOnline = isOnline); + + // Show snackbar notification + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + Icon( + isOnline ? Icons.wifi : Icons.wifi_off, + color: Colors.white, + ), + const SizedBox(width: 8), + Text(isOnline ? 'Back online' : 'You are offline'), + ], + ), + duration: const Duration(seconds: 3), + backgroundColor: isOnline ? Colors.green : Colors.orange, + ), + ); + } + }); + } + + Future _checkInitialStatus() async { + final webUtils = WebUtils(); + final isOnline = await webUtils.isOnline; + if (mounted && _isOnline != isOnline) { + setState(() => _isOnline = isOnline); + } + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + widget.child, + if (!WebUtils.isWeb) + const SizedBox.shrink(), // Don't show on non-web platforms + if (WebUtils.isWeb && !_isOnline) + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + color: Colors.orange, + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + child: Row( + children: [ + const Icon(Icons.wifi_off, color: Colors.white, size: 16), + const SizedBox(width: 8), + Text( + 'You are currently offline', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +/// Web-specific keyboard shortcuts handler +class WebKeyboardShortcuts extends StatefulWidget { + final Widget child; + final Function(String shortcut)? onShortcut; + + const WebKeyboardShortcuts({ + Key? key, + required this.child, + this.onShortcut, + }) : super(key: key); + + @override + State createState() => _WebKeyboardShortcutsState(); +} + +class _WebKeyboardShortcutsState extends State { + final Set _pressedKeys = {}; + + @override + void initState() { + super.initState(); + if (WebUtils.isWeb) { + _setupKeyboardListeners(); + } + } + + @override + void dispose() { + if (WebUtils.isWeb) { + _removeKeyboardListeners(); + } + super.dispose(); + } + + void _setupKeyboardListeners() { + html.document.addEventListener('keydown', _handleKeyDown); + html.document.addEventListener('keyup', _handleKeyUp); + } + + void _removeKeyboardListeners() { + html.document.removeEventListener('keydown', _handleKeyDown); + html.document.removeEventListener('keyup', _handleKeyUp); + } + + void _handleKeyDown(html.KeyboardEvent event) { + final key = event.key?.toLowerCase() ?? ''; + final ctrl = event.ctrlKey || event.metaKey; + final shift = event.shiftKey; + final alt = event.altKey; + + // Build shortcut string + final parts = []; + if (ctrl) parts.add('ctrl'); + if (event.metaKey && !ctrl) parts.add('cmd'); // macOS command key + if (shift) parts.add('shift'); + if (alt) parts.add('alt'); + parts.add(key); + + final shortcut = parts.join('+'); + + // Prevent default browser behavior for our shortcuts + if (_isHandledShortcut(shortcut)) { + event.preventDefault(); + event.stopPropagation(); + + // Only trigger on keydown to avoid repeated triggers + if (!_pressedKeys.contains(shortcut)) { + _pressedKeys.add(shortcut); + widget.onShortcut?.call(shortcut); + } + } + } + + void _handleKeyUp(html.KeyboardEvent event) { + final key = event.key?.toLowerCase() ?? ''; + final ctrl = event.ctrlKey || event.metaKey; + final shift = event.shiftKey; + final alt = event.altKey; + + final parts = []; + if (ctrl) parts.add('ctrl'); + if (event.metaKey && !ctrl) parts.add('cmd'); + if (shift) parts.add('shift'); + if (alt) parts.add('alt'); + parts.add(key); + + final shortcut = parts.join('+'); + _pressedKeys.remove(shortcut); + } + + bool _isHandledShortcut(String shortcut) { + const handledShortcuts = { + 'ctrl+n', 'cmd+n', + 'ctrl+z', 'cmd+z', + 'ctrl+y', 'cmd+y', + 'ctrl+shift+z', 'cmd+shift+z', + 'ctrl+s', 'cmd+s', + 'delete', 'backspace', + 'ctrl+t', 'cmd+t', + 'ctrl+d', 'cmd+d', + 'escape', + }; + + return handledShortcuts.contains(shortcut); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} \ No newline at end of file diff --git a/lib/utils/web_utils.dart b/lib/utils/web_utils.dart new file mode 100644 index 00000000..e80b84cd --- /dev/null +++ b/lib/utils/web_utils.dart @@ -0,0 +1,379 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:html' as html; +import 'dart:js' as js; +import 'dart:js_util' as js_util; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:universal_html/html.dart' as html; + +/// Web-specific utilities for PWA functionality +class WebUtils { + static final WebUtils _instance = WebUtils._internal(); + factory WebUtils() => _instance; + WebUtils._internal(); + + final Connectivity _connectivity = Connectivity(); + StreamSubscription? _connectivitySubscription; + + /// Check if running on web platform + static bool get isWeb => kIsWeb; + + /// Check if PWA is installed + bool get isPWAInstalled { + if (!isWeb) return false; + return html.window.matchMedia('(display-mode: standalone)').matches || + html.window.matchMedia('(display-mode: fullscreen)').matches || + html.window.matchMedia('(display-mode: minimal-ui)').matches; + } + + /// Check if service worker is supported + bool get isServiceWorkerSupported { + if (!isWeb) return false; + return js_util.hasProperty(html.window.navigator, 'serviceWorker'); + } + + /// Get network connectivity status + Future get isOnline async { + if (!isWeb) return true; + try { + final result = await _connectivity.checkConnectivity(); + return result != ConnectivityResult.none; + } catch (e) { + return true; // Assume online if check fails + } + } + + /// Initialize web-specific features + void initialize() { + if (!isWeb) return; + + _setupConnectivityMonitoring(); + _setupKeyboardShortcuts(); + _setupPWAInstallPrompt(); + _setupDragAndDrop(); + } + + /// Setup connectivity monitoring + void _setupConnectivityMonitoring() { + _connectivitySubscription = _connectivity.onConnectivityChanged.listen( + (ConnectivityResult result) { + final isOnline = result != ConnectivityResult.none; + debugPrint('[WebUtils] Network status: ${isOnline ? 'online' : 'offline'}'); + + // Dispatch custom event that can be caught by Flutter + final event = html.CustomEvent('networkstatuschange', detail: isOnline); + html.window.dispatchEvent(event); + }, + ); + } + + /// Setup global keyboard shortcuts for web + void _setupKeyboardShortcuts() { + if (!isWeb) return; + + html.document.onKeyDown.listen((html.KeyboardEvent event) { + // Prevent default browser behavior for our shortcuts + if (_isTextEditorShortcut(event)) { + event.preventDefault(); + _handleKeyboardShortcut(event); + } + }); + } + + /// Check if the key combination is a text editor shortcut + bool _isTextEditorShortcut(html.KeyboardEvent event) { + final ctrlOrCmd = event.ctrlKey || event.metaKey; + + // Common text editor shortcuts + return ctrlOrCmd && ( + event.key == 'n' || // New + event.key == 'o' || // Open + event.key == 's' || // Save + event.key == 'z' || // Undo + event.key == 'y' || // Redo + event.key == 'a' || // Select all + event.key == 'c' || // Copy + event.key == 'v' || // Paste + event.key == 'x' // Cut + ); + } + + /// Handle keyboard shortcuts + void _handleKeyboardShortcut(html.KeyboardEvent event) { + final ctrlOrCmd = event.ctrlKey || event.metaKey; + final shift = event.shiftKey; + + if (!ctrlOrCmd) return; + + switch (event.key.toLowerCase()) { + case 'n': + if (!shift) _triggerAction('new_document'); + break; + case 'o': + _triggerAction('open_document'); + break; + case 's': + if (shift) { + _triggerAction('save_as'); + } else { + _triggerAction('save_document'); + } + break; + case 'z': + if (shift) { + _triggerAction('redo'); + } else { + _triggerAction('undo'); + } + break; + case 'y': + _triggerAction('redo'); + break; + case 'a': + _triggerAction('select_all'); + break; + case 'c': + _triggerAction('copy'); + break; + case 'v': + _triggerAction('paste'); + break; + case 'x': + _triggerAction('cut'); + break; + } + } + + /// Trigger action callback (to be set by the app) + Function(String action)? _actionCallback; + + void setActionCallback(Function(String action) callback) { + _actionCallback = callback; + } + + void _triggerAction(String action) { + _actionCallback?.call(action); + } + + /// Setup PWA install prompt + void _setupPWAInstallPrompt() { + if (!isWeb || isPWAInstalled) return; + + // Listen for the beforeinstallprompt event + html.window.addEventListener('beforeinstallprompt', (html.Event event) { + event.preventDefault(); + final promptEvent = event as html.Event; + + // Store the event for later use + _installPrompt = promptEvent; + + // Notify the app that install is available + final customEvent = html.CustomEvent('pwa_install_available'); + html.window.dispatchEvent(customEvent); + }); + + // Listen for successful installation + html.window.addEventListener('appinstalled', (html.Event event) { + debugPrint('[WebUtils] PWA installed successfully'); + _installPrompt = null; + + final customEvent = html.CustomEvent('pwa_installed'); + html.window.dispatchEvent(customEvent); + }); + } + + html.Event? _installPrompt; + + /// Show PWA install prompt + Future showInstallPrompt() async { + if (_installPrompt == null) return false; + + try { + // Call the prompt + js_util.callMethod(_installPrompt!, 'prompt', []); + + // Wait for user choice + final choice = await js_util.promiseToFuture( + js_util.getProperty(_installPrompt!, 'userChoice') + ); + + final outcome = js_util.getProperty(choice, 'outcome'); + return outcome == 'accepted'; + } catch (e) { + debugPrint('[WebUtils] Install prompt error: $e'); + return false; + } + } + + /// Setup drag and drop functionality + void _setupDragAndDrop() { + if (!isWeb) return; + + // Setup global drag and drop handlers + html.document.body?.addEventListener('dragover', (html.Event event) { + event.preventDefault(); + final dragEvent = event as html.MouseEvent; + dragEvent.dataTransfer?.dropEffect = 'copy'; + }); + + html.document.body?.addEventListener('drop', (html.Event event) { + event.preventDefault(); + final dropEvent = event as html.MouseEvent; + final files = dropEvent.dataTransfer?.files; + + if (files != null && files.isNotEmpty) { + _handleDroppedFiles(files); + } + }); + } + + /// Handle dropped files + void _handleDroppedFiles(html.FileList files) { + final fileList = []; + + for (var i = 0; i < files.length; i++) { + final file = files[i]; + fileList.add(PlatformFile( + name: file.name, + size: file.size, + path: null, // Web files don't have paths + bytes: null, // Will be read asynchronously + readStream: null, + )); + } + + // Notify the app about dropped files + final customEvent = html.CustomEvent('files_dropped', detail: fileList); + html.window.dispatchEvent(customEvent); + } + + /// Read file content as bytes (for web) + Future readFileAsBytes(html.File file) async { + final completer = Completer(); + + final reader = html.FileReader(); + reader.onLoad.listen((html.ProgressEvent event) { + final result = reader.result; + if (result is String) { + completer.complete(Uint8List.fromList(utf8.encode(result))); + } else if (result is Uint8List) { + completer.complete(result); + } else { + completer.complete(null); + } + }); + + reader.onError.listen((html.ProgressEvent event) { + completer.complete(null); + }); + + reader.readAsArrayBuffer(file); + return completer.future; + } + + /// Download file to user's device + void downloadFile(String filename, Uint8List bytes, String mimeType) { + if (!isWeb) return; + + final blob = html.Blob([bytes], mimeType); + final url = html.Url.createObjectUrl(blob); + + final anchor = html.AnchorElement(href: url) + ..download = filename + ..click(); + + html.Url.revokeObjectUrl(url); + } + + /// Share content using Web Share API (if available) + Future shareContent(String title, String text, {String? url}) async { + if (!isWeb) return false; + + try { + if (js_util.hasProperty(html.window.navigator, 'share')) { + final shareData = {'title': title, 'text': text}; + if (url != null) shareData['url'] = url; + + await js_util.promiseToFuture( + js_util.callMethod(html.window.navigator, 'share', [js.JsObject.jsify(shareData)]) + ); + return true; + } + } catch (e) { + debugPrint('[WebUtils] Share failed: $e'); + } + return false; + } + + /// Request notification permission + Future requestNotificationPermission() async { + if (!isWeb) return false; + + try { + if (js_util.hasProperty(html.window, 'Notification')) { + final permission = await js_util.promiseToFuture( + js_util.callMethod(html.window, 'Notification', ['requestPermission']) + ); + return permission == 'granted'; + } + } catch (e) { + debugPrint('[WebUtils] Notification permission request failed: $e'); + } + return false; + } + + /// Show notification + void showNotification(String title, String body, {String? icon}) { + if (!isWeb) return; + + try { + if (js_util.hasProperty(html.window, 'Notification')) { + final options = { + 'body': body, + 'icon': icon ?? '/icons/Icon-192.png', + 'badge': '/icons/Icon-192.png', + }; + + js_util.callConstructor( + js_util.getProperty(html.window, 'Notification'), + [title, js.JsObject.jsify(options)] + ); + } + } catch (e) { + debugPrint('[WebUtils] Show notification failed: $e'); + } + } + + /// Cleanup resources + void dispose() { + _connectivitySubscription?.cancel(); + } +} + +/// Extension methods for web-specific functionality +extension WebBuildContextExtension on BuildContext { + /// Check if running on web + bool get isWeb => WebUtils.isWeb; + + /// Get web utils instance + WebUtils get webUtils => WebUtils(); +} + +/// Keyboard shortcuts for web +class WebKeyboardShortcuts { + static const newDocument = SingleActivator(LogicalKeyboardKey.keyN, control: true); + static const openDocument = SingleActivator(LogicalKeyboardKey.keyO, control: true); + static const saveDocument = SingleActivator(LogicalKeyboardKey.keyS, control: true); + static const saveAsDocument = SingleActivator(LogicalKeyboardKey.keyS, control: true, shift: true); + static const undo = SingleActivator(LogicalKeyboardKey.keyZ, control: true); + static const redo = SingleActivator(LogicalKeyboardKey.keyY, control: true); + static const redoAlt = SingleActivator(LogicalKeyboardKey.keyZ, control: true, shift: true); + static const selectAll = SingleActivator(LogicalKeyboardKey.keyA, control: true); + static const copy = SingleActivator(LogicalKeyboardKey.keyC, control: true); + static const paste = SingleActivator(LogicalKeyboardKey.keyV, control: true); + static const cut = SingleActivator(LogicalKeyboardKey.keyX, control: true); +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index da09048a..e385c859 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,10 @@ dependencies: image_picker: ^1.2.0 path_provider: ^2.1.5 path: ^1.9.1 + universal_html: ^2.2.4 + file_picker: ^8.1.2 + url_launcher: ^6.3.0 + connectivity_plus: ^6.0.5 dev_dependencies: flutter_test: diff --git a/web/index.html b/web/index.html index fa16d27e..99b3669b 100644 --- a/web/index.html +++ b/web/index.html @@ -109,5 +109,146 @@ + + + \ No newline at end of file diff --git a/web/manifest.json b/web/manifest.json index 65297b37..dea4c24c 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,23 +1,52 @@ { - "name": "texterra", - "short_name": "texterra", + "name": "TextEditingApp - Professional Text Editor", + "short_name": "TextEditor", "start_url": ".", "display": "standalone", - "background_color": "#0175C2", + "background_color": "#ffffff", "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", + "description": "Professional text editing app with drawing capabilities. Create, edit, and save text documents with rich formatting options.", + "orientation": "any", "prefer_related_applications": false, + "scope": ".", + "lang": "en-US", + "categories": ["productivity", "utilities", "text"], + "screenshots": [ + { + "src": "screenshots/screenshot1.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide" + } + ], + "shortcuts": [ + { + "name": "New Document", + "short_name": "New", + "description": "Create a new text document", + "url": "/?action=new", + "icons": [{ "src": "icons/Icon-192.png", "sizes": "192x192" }] + }, + { + "name": "Open Recent", + "short_name": "Recent", + "description": "Open recently edited documents", + "url": "/?action=recent", + "icons": [{ "src": "icons/Icon-192.png", "sizes": "192x192" }] + } + ], "icons": [ { "src": "icons/Icon-192.png", "sizes": "192x192", - "type": "image/png" + "type": "image/png", + "purpose": "any" }, { "src": "icons/Icon-512.png", "sizes": "512x512", - "type": "image/png" + "type": "image/png", + "purpose": "any" }, { "src": "icons/Icon-maskable-192.png", diff --git a/web/sw.js b/web/sw.js new file mode 100644 index 00000000..59b74f73 --- /dev/null +++ b/web/sw.js @@ -0,0 +1,217 @@ +// Service Worker for TextEditingApp PWA +const CACHE_NAME = 'texteditor-v1.0.0'; +const STATIC_CACHE = 'texteditor-static-v1.0.0'; +const DYNAMIC_CACHE = 'texteditor-dynamic-v1.0.0'; + +// Files to cache immediately +const STATIC_ASSETS = [ + '/', + '/index.html', + '/manifest.json', + '/flutter.js', + '/flutter_bootstrap.js', + '/main.dart.js', + '/assets/fonts/MaterialIcons-Regular.otf', + '/favicon.png', + '/icons/Icon-192.png', + '/icons/Icon-512.png', + '/icons/Icon-maskable-192.png', + '/icons/Icon-maskable-512.png' +]; + +// Install event - cache static assets +self.addEventListener('install', (event) => { + console.log('[ServiceWorker] Install'); + event.waitUntil( + caches.open(STATIC_CACHE) + .then((cache) => { + console.log('[ServiceWorker] Caching static assets'); + return cache.addAll(STATIC_ASSETS); + }) + .then(() => self.skipWaiting()) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + console.log('[ServiceWorker] Activate'); + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE) { + console.log('[ServiceWorker] Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }).then(() => self.clients.claim()) + ); +}); + +// Fetch event - serve from cache or network +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests + if (request.method !== 'GET') return; + + // Skip cross-origin requests + if (url.origin !== location.origin) return; + + // Handle API requests differently + if (url.pathname.startsWith('/api/')) { + event.respondWith( + fetch(request) + .then((response) => { + // Cache successful API responses + if (response.ok) { + const responseClone = response.clone(); + caches.open(DYNAMIC_CACHE).then((cache) => { + cache.put(request, responseClone); + }); + } + return response; + }) + .catch(() => { + // Return cached API response if available + return caches.match(request); + }) + ); + return; + } + + // Handle static assets and Flutter resources + event.respondWith( + caches.match(request) + .then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + + return fetch(request) + .then((response) => { + // Don't cache non-successful responses + if (!response.ok) return response; + + // Cache the response + const responseClone = response.clone(); + caches.open(DYNAMIC_CACHE).then((cache) => { + cache.put(request, responseClone); + }); + + return response; + }) + .catch(() => { + // Return offline fallback for navigation requests + if (request.mode === 'navigate') { + return caches.match('/index.html'); + } + }); + }) + ); +}); + +// Background sync for offline actions +self.addEventListener('sync', (event) => { + console.log('[ServiceWorker] Background sync:', event.tag); + + if (event.tag === 'background-sync') { + event.waitUntil(doBackgroundSync()); + } +}); + +// Push notifications (for future use) +self.addEventListener('push', (event) => { + console.log('[ServiceWorker] Push received:', event); + + const options = { + body: event.data ? event.data.text() : 'New update available!', + icon: '/icons/Icon-192.png', + badge: '/icons/Icon-192.png', + vibrate: [100, 50, 100], + data: { + dateOfArrival: Date.now(), + primaryKey: 1 + }, + actions: [ + { + action: 'explore', + title: 'Open App', + icon: '/icons/Icon-192.png' + }, + { + action: 'close', + title: 'Close', + icon: '/icons/Icon-192.png' + } + ] + }; + + event.waitUntil( + self.registration.showNotification('TextEditingApp', options) + ); +}); + +// Handle notification clicks +self.addEventListener('notificationclick', (event) => { + console.log('[ServiceWorker] Notification click:', event.action); + + event.notification.close(); + + if (event.action === 'explore') { + event.waitUntil( + clients.openWindow('/') + ); + } +}); + +// Background sync implementation +async function doBackgroundSync() { + try { + const cache = await caches.open(DYNAMIC_CACHE); + const keys = await cache.keys(); + + // Process any pending offline actions + for (const request of keys) { + try { + await fetch(request); + await cache.delete(request); + } catch (error) { + console.log('[ServiceWorker] Background sync failed for:', request.url); + } + } + } catch (error) { + console.error('[ServiceWorker] Background sync error:', error); + } +} + +// Periodic cleanup +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'CLEAN_CACHE') { + cleanOldCache(); + } +}); + +async function cleanOldCache() { + try { + const cache = await caches.open(DYNAMIC_CACHE); + const keys = await cache.keys(); + + // Remove entries older than 1 hour + const oneHourAgo = Date.now() - (60 * 60 * 1000); + + for (const request of keys) { + const response = await cache.match(request); + if (response) { + const date = response.headers.get('date'); + if (date && new Date(date).getTime() < oneHourAgo) { + await cache.delete(request); + } + } + } + } catch (error) { + console.error('[ServiceWorker] Cache cleanup error:', error); + } +} \ No newline at end of file