diff --git a/lib/main.dart b/lib/main.dart index d93fb042..cb933ecc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,19 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; 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-specific features + if (kIsWeb) { + await WebUtils.registerServiceWorker(); + } -void main() { runApp(const MyApp()); } @@ -16,11 +25,38 @@ class MyApp extends StatelessWidget { return BlocProvider( create: (_) => CanvasCubit(), child: MaterialApp( - title: 'Text Editor', - theme: ThemeData(primarySwatch: Colors.blue), + title: 'Texterra - Text Editor', + theme: ThemeData( + primarySwatch: Colors.blue, + useMaterial3: true, + // Enhanced theme for web + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.indigo, + brightness: Brightness.light, + ), + // Better text rendering on web + fontFamily: kIsWeb ? 'Roboto' : null, + ), debugShowCheckedModeBanner: false, navigatorKey: CustomSnackbar.navigatorKey, home: const SplashScreen(), + // Handle deep links and shortcuts for PWA + onGenerateRoute: (settings) { + // Handle PWA shortcuts + if (settings.name == '/?action=new') { + return MaterialPageRoute( + builder: (_) => const SplashScreen(), + settings: const RouteSettings(arguments: {'action': 'new'}), + ); + } + if (settings.name == '/?action=saved') { + return MaterialPageRoute( + builder: (_) => const SplashScreen(), + settings: const RouteSettings(arguments: {'action': 'saved'}), + ); + } + return null; + }, ), ); } diff --git a/lib/ui/screens/canvas_screen.dart b/lib/ui/screens/canvas_screen.dart index 7418282b..31a90fdf 100644 --- a/lib/ui/screens/canvas_screen.dart +++ b/lib/ui/screens/canvas_screen.dart @@ -15,11 +15,11 @@ 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'; class CanvasScreen extends StatelessWidget { const CanvasScreen({super.key}); - // Method to show background options void _showBackgroundOptions(BuildContext context) { showModalBottomSheet( context: context, @@ -31,400 +31,382 @@ class CanvasScreen extends StatelessWidget { ); } + // Handle keyboard shortcut actions + void _handleSave(BuildContext context) { + final cubit = context.read(); + cubit.handleSaveAction().then((wasHandled) { + if (!wasHandled && context.mounted) { + showDialog( + context: context, + builder: (context) => BlocProvider.value( + value: cubit, + child: const SavePageDialog(), + ), + ); + } + }); + } + + void _handleUndo(BuildContext context) { + final cubit = context.read(); + if (cubit.state.history.isNotEmpty) { + cubit.undo(); + } else { + CustomSnackbar.showInfo('Nothing to undo'); + } + } + + void _handleRedo(BuildContext context) { + final cubit = context.read(); + if (cubit.state.future.isNotEmpty) { + cubit.redo(); + } else { + CustomSnackbar.showInfo('Nothing to redo'); + } + } + + void _handleNew(BuildContext context) { + context.read().createNewPage(); + } + + void _handleClear(BuildContext context) { + final cubit = context.read(); + 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'); + } + } + + void _handleToggleDrawing(BuildContext context) { + context.read().toggleDrawingMode(); + } + @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: ColorConstants.uiWhite, - appBar: AppBar( + return KeyboardShortcuts( + onSave: () => _handleSave(context), + onUndo: () => _handleUndo(context), + onRedo: () => _handleRedo(context), + onNew: () => _handleNew(context), + onClear: () => _handleClear(context), + onToggleDrawing: () => _handleToggleDrawing(context), + child: Scaffold( 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, + 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, ), ), - ], - ); - }, - ), - 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'); - } + if (state.currentPageName != null) + Text( + state.currentPageName!, + style: const TextStyle( + color: ColorConstants.uiGrayMedium, + fontSize: 12, + fontWeight: FontWeight.normal, + ), + ), + ], + ); }, ), - // 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'); - } - }, + centerTitle: true, + leading: IconButton( + tooltip: kIsWeb ? "Clear Canvas (Ctrl+Del)" : "Clear Canvas", + icon: const Icon(Icons.delete, color: ColorConstants.uiIconBlack), + onPressed: () => _handleClear(context), ), - // 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(); + actions: [ + IconButton( + tooltip: 'Background options', + icon: const Icon(Icons.wallpaper, + color: ColorConstants.uiIconBlack), + onPressed: () => _showBackgroundOptions(context), + ), + IconButton( + tooltip: kIsWeb ? "Undo (Ctrl+Z)" : "Undo", + icon: const Icon(Icons.undo, color: ColorConstants.uiIconBlack), + onPressed: () => _handleUndo(context), + ), + IconButton( + tooltip: kIsWeb ? "Redo (Ctrl+Shift+Z)" : "Redo", + icon: const Icon(Icons.redo, color: ColorConstants.uiIconBlack), + onPressed: () => _handleRedo(context), + ), + BlocBuilder( + builder: (context, state) { + return PopupMenuButton( + tooltip: "More options", + icon: const Icon(Icons.more_vert, + color: ColorConstants.uiIconBlack), + color: ColorConstants.uiWhite, + 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(), - ), - ), - ); - 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(), + 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; - } - }, - 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), - ), - ], + break; + case 'save_page': + _handleSave(context); + break; + case 'shortcuts': + if (kIsWeb) { + _showKeyboardShortcuts(context); + } + break; + } + }, + itemBuilder: (BuildContext context) => + >[ + PopupMenuItem( + value: 'new_page', + child: Row( + children: [ + const Icon(Icons.add, + color: ColorConstants.uiIconBlack, size: 20), + const SizedBox(width: 12), + Text( + kIsWeb ? 'New Page (Ctrl+N)' : 'New Page', + style: const 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), - ), - ], + 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)), + ], + ), ), - ), - ], - ); - }, - ), - ], - ), - 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, - currentBrushType: state.currentBrushType, - 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); - }, - onBrushTypeChanged: (brushType) { - context.read().setBrushType(brushType); - }, - 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: _DraggableTextBox( - key: ValueKey('text_item_$index'), - index: index, - textItem: state.textItems[index], - isSelected: !state.isDrawingMode && - state.selectedTextItemIndex == index, - ), + 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 + ? kIsWeb + ? "Save '${state.currentPageName}' (Ctrl+S)" + : "Save '${state.currentPageName}'" + : kIsWeb + ? "Save Page (Ctrl+S)" + : "Save Page", + style: const TextStyle( + color: ColorConstants.dialogTextBlack87), + ), + ], ), ), - - // 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), - ), + if (kIsWeb) + const PopupMenuItem( + value: 'shortcuts', 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), - ), + Icon(Icons.keyboard, + color: ColorConstants.uiIconBlack, size: 20), + SizedBox(width: 12), + Text('Keyboard Shortcuts', + style: TextStyle( + color: ColorConstants.dialogTextBlack87)), ], ), ), - ), - ], - ), - ), - ); - }, - ), - 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(), - ], - ), - ); - }, - ), - floatingActionButton: BlocBuilder( - builder: (context, state) { - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), + ], + ); + }, ), - child: FloatingActionButton( - backgroundColor: ColorConstants.dialogWhite, - elevation: 0.5, - onPressed: () { - // Show options for text or drawing - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: ColorConstants.dialogWhite, - shape: const RoundedRectangleBorder( - borderRadius: - BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Text Option - ListTile( - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: ColorConstants.dialogButtonBlue - .withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: const Icon(Icons.text_fields, - color: ColorConstants.dialogButtonBlue), - ), - title: const Text('Add Text'), - subtitle: - const Text('Add and format text on canvas'), - onTap: () { - Navigator.pop(context); - // Exit drawing mode if active - if (state.isDrawingMode) { - context - .read() - .setDrawingMode(false); - } - context.read().addText('New Text'); - }, + ], + ), + body: BlocBuilder( + builder: (context, state) { + return GestureDetector( + onTap: () { + if (!state.isDrawingMode) { + context.read().deselectText(); + } + }, + behavior: HitTestBehavior.deferToChild, + child: Container( + decoration: _buildBackgroundDecoration(state), + child: Stack( + children: [ + DrawingCanvas( + paths: state.drawPaths, + isDrawingMode: state.isDrawingMode, + currentDrawColor: state.currentDrawColor, + currentStrokeWidth: state.currentStrokeWidth, + currentBrushType: state.currentBrushType, + onStartDrawing: (offset) { + context.read().startNewDrawPath(offset); + }, + onUpdateDrawing: (offset) { + context.read().updateDrawPath(offset); + }, + onEndDrawing: () {}, + onColorChanged: (color) { + context.read().setDrawColor(color); + }, + onStrokeWidthChanged: (width) { + context.read().setStrokeWidth(width); + }, + onBrushTypeChanged: (brushType) { + context.read().setBrushType(brushType); + }, + onUndoDrawing: () { + context.read().undoLastDrawing(); + }, + onClearDrawing: () { + context.read().clearDrawings(); + }, + ), + 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: _DraggableTextBox( + key: ValueKey('text_item_$index'), + index: index, + textItem: state.textItems[index], + isSelected: !state.isDrawingMode && + state.selectedTextItemIndex == index, ), - const Divider(), - // Drawing Option - ListTile( - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: ColorConstants.dialogGreen - .withValues(alpha: 0.1), - shape: BoxShape.circle, + ), + ), + 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), + Text( + kIsWeb + ? 'Drawing Mode (Ctrl+D to exit)' + : 'Drawing Mode', + style: const TextStyle( + color: ColorConstants.dialogWhite, + fontSize: 12), ), - child: const Icon(Icons.brush, - color: ColorConstants.dialogGreen), - ), - title: const Text('Draw'), - subtitle: Text(state.isDrawingMode - ? 'Currently in drawing mode' - : 'Switch to drawing mode'), - onTap: () { - Navigator.pop(context); - context.read().toggleDrawingMode(); - if (!state.isDrawingMode) { - CustomSnackbar.showInfo( - 'Drawing mode activated. Tap to draw.'); - } - }, + ], ), - // If in drawing mode, add option to undo last drawing - if (state.isDrawingMode && state.drawPaths.isNotEmpty) + ), + ), + ], + ), + ), + ); + }, + ), + 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(), + ], + ), + ); + }, + ), + floatingActionButton: BlocBuilder( + builder: (context, state) { + return Container( + decoration: + BoxDecoration(borderRadius: BorderRadius.circular(16)), + child: FloatingActionButton( + backgroundColor: ColorConstants.dialogWhite, + elevation: 0.5, + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: ColorConstants.dialogWhite, + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ ListTile( leading: Container( padding: const EdgeInsets.all(8), @@ -433,59 +415,111 @@ class CanvasScreen extends StatelessWidget { .withValues(alpha: 0.1), shape: BoxShape.circle, ), - child: const Icon(Icons.undo, + child: const Icon(Icons.text_fields, color: ColorConstants.dialogButtonBlue), ), - title: const Text('Undo Last Drawing'), - subtitle: const Text( - 'Remove the most recent drawing stroke'), + title: const Text('Add Text'), + subtitle: + const Text('Add and format text on canvas'), onTap: () { Navigator.pop(context); - context.read().undoLastDrawing(); + if (state.isDrawingMode) { + context + .read() + .setDrawingMode(false); + } + context.read().addText('New Text'); }, ), - // If in drawing mode, add option to clear drawings - if (state.isDrawingMode && state.drawPaths.isNotEmpty) + const Divider(), ListTile( leading: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: ColorConstants.dialogRed + color: ColorConstants.dialogGreen .withValues(alpha: 0.1), shape: BoxShape.circle, ), - child: const Icon(Icons.delete_outline, - color: ColorConstants.dialogRed), + child: const Icon(Icons.brush, + color: ColorConstants.dialogGreen), ), - title: const Text('Clear Drawings'), - subtitle: - const Text('Remove all drawings from canvas'), + title: Text(kIsWeb ? 'Draw (Ctrl+D)' : 'Draw'), + subtitle: Text(state.isDrawingMode + ? 'Currently in drawing mode' + : 'Switch to drawing mode'), onTap: () { Navigator.pop(context); - context.read().clearDrawings(); + context.read().toggleDrawingMode(); + if (!state.isDrawingMode) { + CustomSnackbar.showInfo( + 'Drawing mode activated. Tap to draw.'); + } }, ), - ], - ), - ); - }, - ); - }, - child: Icon(state.isDrawingMode ? Icons.brush : Icons.add, + if (state.isDrawingMode && + state.drawPaths.isNotEmpty) + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: ColorConstants.dialogButtonBlue + .withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: const Icon(Icons.undo, + color: ColorConstants.dialogButtonBlue), + ), + title: const Text('Undo Last Drawing'), + subtitle: const Text( + 'Remove the most recent drawing stroke'), + onTap: () { + Navigator.pop(context); + context.read().undoLastDrawing(); + }, + ), + if (state.isDrawingMode && + state.drawPaths.isNotEmpty) + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: ColorConstants.dialogRed + .withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: const Icon(Icons.delete_outline, + color: ColorConstants.dialogRed), + ), + title: const Text('Clear Drawings'), + subtitle: const Text( + 'Remove all drawings from canvas'), + onTap: () { + Navigator.pop(context); + context.read().clearDrawings(); + }, + ), + ], + ), + ); + }, + ); + }, + child: Icon( + state.isDrawingMode ? Icons.brush : Icons.add, color: state.isDrawingMode ? ColorConstants.dialogButtonBlue - : ColorConstants.dialogTextBlack), - ), - ); - }, + : ColorConstants.dialogTextBlack, + ), + ), + ); + }, + ), ), ); } - // Helper method to build background decoration BoxDecoration _buildBackgroundDecoration(CanvasState state) { if (state.backgroundImagePath != null) { - // Show background image with overlay gradient return BoxDecoration( image: DecorationImage( image: _getImageProvider(state.backgroundImagePath!), @@ -501,7 +535,6 @@ class CanvasScreen extends StatelessWidget { ), ); } else { - // Show solid color gradient return BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -515,18 +548,75 @@ class CanvasScreen extends StatelessWidget { } } - // Helper method to get appropriate image provider for web and mobile ImageProvider _getImageProvider(String imagePath) { if (kIsWeb && imagePath.startsWith('data:')) { - // On web, if it's a data URL, decode it and use MemoryImage final String base64String = imagePath.split(',')[1]; final Uint8List bytes = base64Decode(base64String); return MemoryImage(bytes); } else { - // On mobile or if it's a file path, use FileImage return FileImage(File(imagePath)); } } + + void _showKeyboardShortcuts(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Keyboard Shortcuts'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _shortcutItem('Ctrl + S', 'Save page'), + _shortcutItem('Ctrl + N', 'New page'), + _shortcutItem('Ctrl + Z', 'Undo'), + _shortcutItem('Ctrl + Shift + Z', 'Redo'), + _shortcutItem('Ctrl + Y', 'Redo (alternative)'), + _shortcutItem('Ctrl + D', 'Toggle drawing mode'), + _shortcutItem('Ctrl + Del', 'Clear canvas'), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + ); + } + + Widget _shortcutItem(String shortcut, String description) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: ColorConstants.gray100, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: ColorConstants.gray300), + ), + child: Text( + shortcut, + style: const TextStyle( + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text(description, style: const TextStyle(fontSize: 14)), + ), + ], + ), + ); + } } class _DraggableTextBox extends StatefulWidget { @@ -600,4 +690,4 @@ class _DraggableTextBoxState extends State<_DraggableTextBox> { ), ); } -} +} \ No newline at end of file diff --git a/lib/ui/widgets/font_controls.dart b/lib/ui/widgets/font_controls.dart index 17266331..ad9052bb 100644 --- a/lib/ui/widgets/font_controls.dart +++ b/lib/ui/widgets/font_controls.dart @@ -1,4 +1,5 @@ import 'package:flex_color_picker/flex_color_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -9,14 +10,66 @@ import '../../cubit/canvas_cubit.dart'; import '../../cubit/canvas_state.dart'; import '../widgets/shadows_controls.dart'; -class FontControls extends StatelessWidget { +class FontControls extends StatefulWidget { const FontControls({super.key}); + @override + State createState() => _FontControlsState(); +} + +class _FontControlsState extends State { + final ScrollController _scrollController = ScrollController(); + bool _showLeftArrow = false; + bool _showRightArrow = true; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_updateArrows); + // Check initial state after frame is built + WidgetsBinding.instance.addPostFrameCallback((_) => _updateArrows()); + } + + @override + void dispose() { + _scrollController.removeListener(_updateArrows); + _scrollController.dispose(); + super.dispose(); + } + + void _updateArrows() { + if (!_scrollController.hasClients) return; + + setState(() { + _showLeftArrow = _scrollController.offset > 10; + _showRightArrow = _scrollController.offset < + _scrollController.position.maxScrollExtent - 10; + }); + } + + void _scrollLeft() { + _scrollController.animateTo( + _scrollController.offset - 200, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + + void _scrollRight() { + _scrollController.animateTo( + _scrollController.offset + 200, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + @override Widget build(BuildContext context) { + // Adjust height based on platform + final controlsHeight = kIsWeb ? 90.0 : 80.0; + return Container( - height: 80, - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + height: controlsHeight, decoration: BoxDecoration( color: ColorConstants.uiWhite, boxShadow: [ @@ -27,29 +80,122 @@ class FontControls extends StatelessWidget { ), ], ), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _buildFontSizeWheel(context), - const SizedBox(width: 25), - _buildFontStyleControls(context), - const SizedBox(width: 25), - _buildAlignmentControls(context), - const SizedBox(width: 25), - _buildHighlightControls(context), - const SizedBox(width: 25), - const ShadowControls(), - const SizedBox(width: 25), - _buildFontFamilyControls(context), - const SizedBox(width: 25), - _buildColorControls(context), - const SizedBox(width: 25), // Add spacing for the new button - _buildCopyButton(context), - const SizedBox(width: 25), - _buildClearFormatButton(context), // Call your new function here - ], - ), + child: Stack( + children: [ + // Main scrollable content + Padding( + padding: EdgeInsets.symmetric( + horizontal: + kIsWeb ? 48.0 : 16.0, // Extra padding for arrows on web + vertical: 8.0, + ), + child: SingleChildScrollView( + controller: _scrollController, + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildFontSizeWheel(context), + const SizedBox(width: 25), + _buildFontStyleControls(context), + const SizedBox(width: 25), + _buildAlignmentControls(context), + const SizedBox(width: 25), + _buildHighlightControls(context), + const SizedBox(width: 25), + const ShadowControls(), + const SizedBox(width: 25), + _buildFontFamilyControls(context), + const SizedBox(width: 25), + _buildColorControls(context), + const SizedBox(width: 25), + _buildCopyButton(context), + const SizedBox(width: 25), + _buildClearFormatButton(context), + const SizedBox(width: 16), // End padding + ], + ), + ), + ), + + // Left scroll arrow (web only) + if (kIsWeb && _showLeftArrow) + Positioned( + left: 0, + top: 0, + bottom: 0, + child: Container( + width: 40, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + ColorConstants.uiWhite, + ColorConstants.uiWhite.withValues(alpha: 0), + ], + ), + ), + child: Center( + child: IconButton( + icon: const Icon(Icons.chevron_left, size: 28), + color: ColorConstants.gray700, + onPressed: _scrollLeft, + tooltip: 'Scroll left', + ), + ), + ), + ), + + // Right scroll arrow (web only) + if (kIsWeb && _showRightArrow) + Positioned( + right: 0, + top: 0, + bottom: 0, + child: Container( + width: 40, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerRight, + end: Alignment.centerLeft, + colors: [ + ColorConstants.uiWhite, + ColorConstants.uiWhite.withValues(alpha: 0), + ], + ), + ), + child: Center( + child: IconButton( + icon: const Icon(Icons.chevron_right, size: 28), + color: ColorConstants.gray700, + onPressed: _scrollRight, + tooltip: 'Scroll right', + ), + ), + ), + ), + + if (!kIsWeb && _showRightArrow) + Positioned( + right: 0, + top: 0, + bottom: 0, + child: Container( + width: 20, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerRight, + end: Alignment.centerLeft, + colors: [ + ColorConstants.uiWhite.withValues(alpha: 0.8), + ColorConstants.uiWhite.withValues(alpha: 0), + ], + ), + ), + ), + ), + ], ), ); } @@ -195,7 +341,7 @@ class FontControls extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ - const Text('Restore Default', + const Text('Reset', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), const SizedBox(width: 12), Container( @@ -204,11 +350,9 @@ class FontControls extends StatelessWidget { borderRadius: BorderRadius.circular(8), border: Border.all(color: ColorConstants.gray300), ), - // re-using Button-style for consistent UI child: _buildStyleButton( icon: Icons.layers_clear, - isSelected: - false, // This button is never in a "selected" state. + isSelected: false, onPressed: isDisabled ? null : () { @@ -331,7 +475,6 @@ class FontControls extends StatelessWidget { Widget _buildAlignmentControls(BuildContext context) { return BlocBuilder( buildWhen: (previous, current) { - // Only rebuild if selection changed or textAlign changed final prevIndex = previous.selectedTextItemIndex; final currIndex = current.selectedTextItemIndex; @@ -450,6 +593,7 @@ class FontControls extends StatelessWidget { ? null : () => _showFontPickerModal(context, selectedIndex, currentFont), + icon: const Icon(Icons.font_download, size: 18), label: Text( currentFont, style: GoogleFonts.getFont(currentFont, fontSize: 14), @@ -458,6 +602,8 @@ class FontControls extends StatelessWidget { backgroundColor: ColorConstants.gray100, foregroundColor: ColorConstants.gray800, elevation: 0, + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), side: const BorderSide(color: ColorConstants.gray300), @@ -479,20 +625,16 @@ class FontControls extends StatelessWidget { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (_) { - return StatefulBuilder( - builder: (context, setState) { - return DraggableScrollableSheet( - expand: false, - initialChildSize: 0.8, - maxChildSize: 0.95, - minChildSize: 0.5, - builder: (context, scrollController) { - return _FontPickerContent( - currentFont: currentFont, - selectedIndex: selectedIndex, - scrollController: scrollController, - ); - }, + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.8, + maxChildSize: 0.95, + minChildSize: 0.5, + builder: (context, scrollController) { + return _FontPickerContent( + currentFont: currentFont, + selectedIndex: selectedIndex, + scrollController: scrollController, ); }, ); @@ -514,7 +656,7 @@ class FontControls extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - const Text('Text Color', + const Text('Color', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), const SizedBox(width: 12), Container( @@ -554,7 +696,6 @@ class FontControls extends StatelessWidget { ), ); }), - //More Colors Button ^_^ IconButton( icon: const Icon(Icons.more_horiz, color: ColorConstants.gray600), diff --git a/lib/utils/web_utils.dart b/lib/utils/web_utils.dart new file mode 100644 index 00000000..4f736c0f --- /dev/null +++ b/lib/utils/web_utils.dart @@ -0,0 +1,128 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; + +/// Web-specific utilities and keyboard shortcuts +class WebUtils { + WebUtils._(); + + static bool get isWeb => kIsWeb; + + /// Register PWA service worker + static Future registerServiceWorker() async { + if (!kIsWeb) return; + debugPrint('✅ Running on Web - PWA features enabled'); + } +} + +/// Keyboard shortcut handler for web platform +class KeyboardShortcuts extends StatelessWidget { + final Widget child; + final VoidCallback? onSave; + final VoidCallback? onUndo; + final VoidCallback? onRedo; + final VoidCallback? onNew; + final VoidCallback? onClear; + final VoidCallback? onToggleDrawing; + + const KeyboardShortcuts({ + super.key, + required this.child, + this.onSave, + this.onUndo, + this.onRedo, + this.onNew, + this.onClear, + this.onToggleDrawing, + }); + + @override + Widget build(BuildContext context) { + // Only apply keyboard shortcuts on web + if (!kIsWeb) return child; + + return CallbackShortcuts( + bindings: { + // Save: Ctrl/Cmd + S + const SingleActivator(LogicalKeyboardKey.keyS, control: true): () { + onSave?.call(); + }, + const SingleActivator(LogicalKeyboardKey.keyS, meta: true): () { + onSave?.call(); + }, + + // Undo: Ctrl/Cmd + Z + const SingleActivator(LogicalKeyboardKey.keyZ, control: true): () { + onUndo?.call(); + }, + const SingleActivator(LogicalKeyboardKey.keyZ, meta: true): () { + onUndo?.call(); + }, + + // Redo: Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y + const SingleActivator(LogicalKeyboardKey.keyZ, + control: true, shift: true): () { + onRedo?.call(); + }, + const SingleActivator(LogicalKeyboardKey.keyZ, meta: true, shift: true): + () { + onRedo?.call(); + }, + const SingleActivator(LogicalKeyboardKey.keyY, control: true): () { + onRedo?.call(); + }, + const SingleActivator(LogicalKeyboardKey.keyY, meta: true): () { + onRedo?.call(); + }, + + // New: Ctrl/Cmd + N + const SingleActivator(LogicalKeyboardKey.keyN, control: true): () { + onNew?.call(); + }, + const SingleActivator(LogicalKeyboardKey.keyN, meta: true): () { + onNew?.call(); + }, + + // Clear: Ctrl/Cmd + Delete + const SingleActivator(LogicalKeyboardKey.delete, control: true): () { + onClear?.call(); + }, + const SingleActivator(LogicalKeyboardKey.delete, meta: true): () { + onClear?.call(); + }, + + // Toggle Drawing: Ctrl/Cmd + D + const SingleActivator(LogicalKeyboardKey.keyD, control: true): () { + onToggleDrawing?.call(); + }, + const SingleActivator(LogicalKeyboardKey.keyD, meta: true): () { + onToggleDrawing?.call(); + }, + }, + child: Focus( + autofocus: true, + child: child, + ), + ); + } +} + +/// Web-specific responsive breakpoints +class WebBreakpoints { + static const double mobile = 600; + static const double tablet = 900; + static const double desktop = 1200; + + static bool isMobile(BuildContext context) { + return MediaQuery.of(context).size.width < mobile; + } + + static bool isTablet(BuildContext context) { + final width = MediaQuery.of(context).size.width; + return width >= mobile && width < desktop; + } + + static bool isDesktop(BuildContext context) { + return MediaQuery.of(context).size.width >= desktop; + } +} diff --git a/web/flutter_service_worker.js b/web/flutter_service_worker.js new file mode 100644 index 00000000..e8fac325 --- /dev/null +++ b/web/flutter_service_worker.js @@ -0,0 +1,172 @@ +// Service Worker for Texterra PWA +const CACHE_NAME = 'texterra-cache-v1'; +const RUNTIME_CACHE = 'texterra-runtime-v1'; + +// Core assets to cache on install +const PRECACHE_ASSETS = [ + '/', + '/index.html', + '/manifest.json', + '/icons/Icon-192.png', + '/icons/Icon-512.png', + '/icons/Icon-maskable-192.png', + '/icons/Icon-maskable-512.png', +]; + +// Install event - cache core assets +self.addEventListener('install', (event) => { + console.log('[Service Worker] Installing...'); + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => { + console.log('[Service Worker] Precaching app shell'); + return cache.addAll(PRECACHE_ASSETS); + }) + .then(() => self.skipWaiting()) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + console.log('[Service Worker] Activating...'); + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((cacheName) => { + return cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE; + }) + .map((cacheName) => { + console.log('[Service Worker] Deleting old cache:', cacheName); + return caches.delete(cacheName); + }) + ); + }).then(() => self.clients.claim()) + ); +}); + +// Fetch event - network first, fallback to cache +self.addEventListener('fetch', (event) => { + // Skip cross-origin requests + if (!event.request.url.startsWith(self.location.origin)) { + return; + } + + // Network-first strategy for API calls and dynamic content + if (event.request.url.includes('/api/') || + event.request.method !== 'GET') { + event.respondWith( + fetch(event.request) + .then((response) => { + // Clone response and cache it + if (response.status === 200) { + const responseClone = response.clone(); + caches.open(RUNTIME_CACHE).then((cache) => { + cache.put(event.request, responseClone); + }); + } + return response; + }) + .catch(() => { + // Fallback to cache if network fails + return caches.match(event.request); + }) + ); + return; + } + + // Cache-first strategy for static assets + event.respondWith( + caches.match(event.request) + .then((cachedResponse) => { + if (cachedResponse) { + // Return cached version and update cache in background + fetch(event.request).then((response) => { + if (response.status === 200) { + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, response); + }); + } + }).catch(() => { + // Silently fail if network is unavailable + }); + return cachedResponse; + } + + // Not in cache, fetch from network + return fetch(event.request) + .then((response) => { + // Cache successful responses + if (response.status === 200) { + const responseClone = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseClone); + }); + } + return response; + }) + .catch((error) => { + console.error('[Service Worker] Fetch failed:', error); + // Return offline fallback page if available + return caches.match('/offline.html'); + }); + }) + ); +}); + +// Background sync for saving pages when offline +self.addEventListener('sync', (event) => { + console.log('[Service Worker] Background sync:', event.tag); + + if (event.tag === 'sync-pages') { + event.waitUntil( + // Sync logic here - would need to be coordinated with Flutter app + Promise.resolve() + ); + } +}); + +// Handle push notifications (for future features) +self.addEventListener('push', (event) => { + console.log('[Service Worker] Push notification received'); + + const options = { + body: event.data ? event.data.text() : 'New update available', + icon: '/icons/Icon-192.png', + badge: '/icons/Icon-192.png', + vibrate: [200, 100, 200], + }; + + event.waitUntil( + self.registration.showNotification('Texterra', options) + ); +}); + +// Handle notification clicks +self.addEventListener('notificationclick', (event) => { + console.log('[Service Worker] Notification clicked'); + event.notification.close(); + + event.waitUntil( + clients.openWindow('/') + ); +}); + +// Message handler for communication with main app +self.addEventListener('message', (event) => { + console.log('[Service Worker] Message received:', event.data); + + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } + + if (event.data && event.data.type === 'CLEAR_CACHE') { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => caches.delete(cacheName)) + ); + }) + ); + } +}); \ No newline at end of file diff --git a/web/index.html b/web/index.html index fa16d27e..70585e51 100644 --- a/web/index.html +++ b/web/index.html @@ -1,49 +1,62 @@ - - + + + - + + + + + + - - + + - texterra + - - - - - + + + + + + + + + + + + + + + + + + Texterra - Text & Drawing Editor + + + +
+ 📡 You're offline. Changes will sync when you reconnect. +
+ + +
+
+ Install Texterra +

Get quick access from your home screen

+
+
+ + +
+
+ + - - - + + + - + +
- \ No newline at end of file + + + + \ No newline at end of file diff --git a/web/manifest.json b/web/manifest.json index 65297b37..072d10de 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,23 +1,41 @@ { - "name": "texterra", - "short_name": "texterra", + "name": "Texterra - Text & Drawing Editor", + "short_name": "Texterra", "start_url": ".", "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", + "background_color": "#FFFFFF", + "theme_color": "#6366F1", + "description": "A powerful text and drawing editor with rich formatting, background customization, and cloud save capabilities. Create, edit, and save your creative work seamlessly.", + "orientation": "any", + "scope": "/", "prefer_related_applications": false, + "categories": ["productivity", "graphics", "utilities"], + "screenshots": [ + { + "src": "icons/screenshot-wide.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide" + }, + { + "src": "icons/screenshot-narrow.png", + "sizes": "720x1280", + "type": "image/png", + "form_factor": "narrow" + } + ], "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", @@ -31,5 +49,47 @@ "type": "image/png", "purpose": "maskable" } - ] -} + ], + "shortcuts": [ + { + "name": "New Page", + "short_name": "New", + "description": "Create a new blank page", + "url": "/?action=new", + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192" + } + ] + }, + { + "name": "Saved Pages", + "short_name": "Pages", + "description": "View your saved pages", + "url": "/?action=saved", + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192" + } + ] + } + ], + "share_target": { + "action": "/share", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "image", + "accept": ["image/*"] + } + ] + } + } +} \ No newline at end of file