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 @@
[](#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 | โ
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 @@
+
+
+