diff --git a/.gitignore b/.gitignore index 5f5e3eeb..8e6c94dc 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/lib/cubit/canvas_cubit.dart b/lib/cubit/canvas_cubit.dart index a08e7638..7188d1d9 100644 --- a/lib/cubit/canvas_cubit.dart +++ b/lib/cubit/canvas_cubit.dart @@ -12,6 +12,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import 'package:texterra/utils/custom_snackbar.dart'; import '../models/text_item_model.dart'; +import '../models/draw_model.dart'; import 'canvas_state.dart'; class CanvasCubit extends Cubit { @@ -31,6 +32,27 @@ class CanvasCubit extends Cubit { } } + // Add to your CanvasCubit class + void fillCanvas(Color fillColor) { + // Create a special "fill" path that covers the entire canvas + final paint = Paint() + ..color = fillColor + ..style = PaintingStyle.fill; + + // Create a single point path with a special flag indicating it's a fill + final fillPath = DrawPath( + points: [DrawingPoint(offset: Offset.zero, paint: paint)], + color: fillColor, + strokeWidth: 1.0, + isFill: true, // New flag to indicate this is a fill operation + ); + + // Add this to the beginning of paths so it appears as a background + final updatedPaths = [fillPath, ...state.drawPaths]; + + emit(state.copyWith(drawPaths: updatedPaths)); + } + // method to deselect text void deselectText() { emit(state.copyWith(selectedTextItemIndex: null, deselect: true)); @@ -312,10 +334,12 @@ class CanvasCubit extends Cubit { emit( state.copyWith( textItems: [], + drawPaths: [], history: [...state.history, state], future: [], selectedTextItemIndex: null, deselect: true, + isDrawingMode: false, // Exit drawing mode when clearing clearCurrentPageName: true, clearBackgroundImage: true, ), @@ -364,14 +388,17 @@ class CanvasCubit extends Cubit { 'fontSize': item.fontSize, 'fontWeight': item.fontWeight.index, 'fontStyle': item.fontStyle.index, - 'color': item.color.value, + 'color': item.color.toARGB32(), 'fontFamily': item.fontFamily, 'isUnderlined': item.isUnderlined, 'textAlign': item.textAlign.index, // Save alignment as integer }) .toList(), - 'backgroundColor': state.backgroundColor.value, + 'drawPaths': state.drawPaths + .map((path) => path.toJson()) + .toList(), // Save drawing paths + 'backgroundColor': state.backgroundColor.toARGB32(), 'backgroundImagePath': state.backgroundImagePath, // Save background image path 'timestamp': DateTime.now().millisecondsSinceEpoch, @@ -459,6 +486,15 @@ class CanvasCubit extends Cubit { ); }).toList(); + // Load drawing paths if they exist + List drawPaths = []; + if (pageData.containsKey('drawPaths')) { + final drawPathsData = pageData['drawPaths'] as List; + drawPaths = drawPathsData.map((pathData) { + return DrawPath.fromJson(pathData); + }).toList(); + } + // Load background image path if it exists final backgroundImagePath = pageData['backgroundImagePath'] as String?; @@ -479,10 +515,11 @@ class CanvasCubit extends Cubit { } } - log('✅ Successfully loaded ${textItems.length} text items'); + log('✅ Successfully loaded ${textItems.length} text items and ${drawPaths.length} drawing paths'); emit(CanvasState( textItems: textItems, + drawPaths: drawPaths, backgroundColor: Color(pageData['backgroundColor']), backgroundImagePath: validImagePath, selectedTextItemIndex: null, @@ -509,10 +546,12 @@ class CanvasCubit extends Cubit { emit(state.copyWith( textItems: [], + drawPaths: [], // Clear drawing paths too backgroundColor: ColorConstants.dialogTextBlack, selectedTextItemIndex: null, history: [], future: [], + isDrawingMode: false, // Exit drawing mode when creating new page clearCurrentPageName: true, clearBackgroundImage: true, message: 'New page created', @@ -708,4 +747,181 @@ class CanvasCubit extends Cubit { CustomSnackbar.showError('Failed to copy: $error'); }); } + + // Drawing related methods + + // Toggle between drawing mode and text mode + void toggleDrawingMode() { + emit(state.copyWith( + isDrawingMode: !state.isDrawingMode, + selectedTextItemIndex: null, + deselect: true, + )); + } + + // Set drawing mode explicitly + void setDrawingMode(bool isDrawing) { + if (state.isDrawingMode != isDrawing) { + emit(state.copyWith( + isDrawingMode: isDrawing, + selectedTextItemIndex: null, + deselect: true, + )); + } + } + + // Set the current drawing color + void setDrawColor(Color color) { + emit(state.copyWith(currentDrawColor: color)); + } + + // Set the current stroke width + void setStrokeWidth(double width) { + emit(state.copyWith(currentStrokeWidth: width)); + } + + // Set the current brush type + void setBrushType(BrushType brushType) { + emit(state.copyWith(currentBrushType: brushType)); + } + + // Create paint for different brush types + Paint _createPaintForBrush( + BrushType brushType, Color color, double strokeWidth) { + final paint = Paint() + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke; + + switch (brushType) { + case BrushType.brush: + paint + ..color = color + ..strokeWidth = strokeWidth + ..filterQuality = FilterQuality.high; + break; + case BrushType.marker: + paint + ..color = color.withValues(alpha: 0.7) + ..strokeWidth = strokeWidth * 1.5 + ..filterQuality = FilterQuality.medium; + break; + case BrushType.highlighter: + paint + ..color = color.withValues(alpha: 0.3) + ..strokeWidth = strokeWidth * 2.0 + ..blendMode = BlendMode.multiply; + break; + case BrushType.pencil: + paint + ..color = color + ..strokeWidth = strokeWidth * 0.8 + ..filterQuality = FilterQuality.low; + break; + } + return paint; + } + + // Add a new drawing path + void startNewDrawPath(Offset point) { + if (!state.isDrawingMode) return; + + final paint = _createPaintForBrush( + state.currentBrushType, + state.currentDrawColor, + state.currentStrokeWidth, + ); + + final drawingPoint = DrawingPoint( + offset: point, + paint: paint, + ); + + final newPath = DrawPath( + points: [drawingPoint], + color: state.currentDrawColor, + strokeWidth: state.currentStrokeWidth, + brushType: state.currentBrushType, + ); + + final newPaths = List.from(state.drawPaths)..add(newPath); + + // Save current state to history + final historyState = state.copyWith(); + final newHistory = List.from(state.history)..add(historyState); + + emit(state.copyWith( + drawPaths: newPaths, + history: newHistory, + future: [], // Clear future as we've made a new action + )); + } + + // Update the current drawing path with a new point + void updateDrawPath(Offset point) { + if (!state.isDrawingMode || state.drawPaths.isEmpty) return; + + final paint = _createPaintForBrush( + state.currentBrushType, + state.currentDrawColor, + state.currentStrokeWidth, + ); + + final drawingPoint = DrawingPoint( + offset: point, + paint: paint, + ); + + final currentPaths = List.from(state.drawPaths); + final currentPath = currentPaths.last; + final updatedPoints = List.from(currentPath.points) + ..add(drawingPoint); + + currentPaths[currentPaths.length - 1] = DrawPath( + points: updatedPoints, + color: currentPath.color, + strokeWidth: currentPath.strokeWidth, + brushType: currentPath.brushType, + ); + + emit(state.copyWith(drawPaths: currentPaths)); + } + + // Clear all drawing paths + void clearDrawings() { + if (state.drawPaths.isEmpty) return; + + // Save current state to history + final historyState = state.copyWith(); + final newHistory = List.from(state.history)..add(historyState); + + emit(state.copyWith( + drawPaths: [], + history: newHistory, + future: [], // Clear future as we've made a new action + )); + + CustomSnackbar.showInfo('All drawings cleared'); + } + + // Undo the last drawing stroke + void undoLastDrawing() { + if (state.drawPaths.isEmpty) return; + + // Save current state to history + final historyState = state.copyWith(); + final newHistory = List.from(state.history)..add(historyState); + + // Remove the last path + final newPaths = List.from(state.drawPaths); + newPaths.removeLast(); + + emit(state.copyWith( + drawPaths: newPaths, + history: newHistory, + future: [], // Clear future as we've made a new action + )); + + CustomSnackbar.showInfo('Last stroke undone'); + } } diff --git a/lib/cubit/canvas_state.dart b/lib/cubit/canvas_state.dart index c12f22c1..49af8721 100644 --- a/lib/cubit/canvas_state.dart +++ b/lib/cubit/canvas_state.dart @@ -1,33 +1,45 @@ import 'package:flutter/material.dart'; import '../models/text_item_model.dart'; import '../constants/color_constants.dart'; +import '../models/draw_model.dart'; class CanvasState { final List textItems; + final List drawPaths; final List history; final List future; final Color backgroundColor; - final String? backgroundImagePath; + final String? backgroundImagePath; final int? selectedTextItemIndex; final bool isTrayShown; final String? message; final String? currentPageName; + final bool isDrawingMode; + final Color currentDrawColor; + final double currentStrokeWidth; + final BrushType currentBrushType; const CanvasState({ required this.textItems, + required this.drawPaths, required this.history, required this.future, this.backgroundColor = ColorConstants.backgroundDarkGray, - this.backgroundImagePath, + this.backgroundImagePath, this.selectedTextItemIndex, this.isTrayShown = false, this.message, this.currentPageName, + this.isDrawingMode = false, + this.currentDrawColor = ColorConstants.dialogTextBlack, + this.currentStrokeWidth = 5.0, + this.currentBrushType = BrushType.brush, }); factory CanvasState.initial() { return const CanvasState( textItems: [], + drawPaths: [], history: [], future: [], backgroundColor: ColorConstants.backgroundDarkGray, @@ -36,11 +48,16 @@ class CanvasState { isTrayShown: false, message: null, currentPageName: null, + isDrawingMode: false, + currentDrawColor: ColorConstants.dialogTextBlack, + currentStrokeWidth: 5.0, + currentBrushType: BrushType.brush, ); } CanvasState copyWith({ List? textItems, + List? drawPaths, List? history, List? future, Color? backgroundColor, @@ -49,47 +66,72 @@ class CanvasState { int? selectedTextItemIndex, bool deselect = false, bool? isTrayShown, + bool? isDrawingMode, + Color? currentDrawColor, + double? currentStrokeWidth, + BrushType? currentBrushType, String? message, String? currentPageName, bool clearCurrentPageName = false, }) { return CanvasState( textItems: textItems ?? this.textItems, + drawPaths: drawPaths ?? this.drawPaths, history: history ?? this.history, future: future ?? this.future, backgroundColor: backgroundColor ?? this.backgroundColor, - backgroundImagePath: clearBackgroundImage - ? null + backgroundImagePath: clearBackgroundImage + ? null : (backgroundImagePath ?? this.backgroundImagePath), - selectedTextItemIndex: deselect ? null : (selectedTextItemIndex ?? this.selectedTextItemIndex), + selectedTextItemIndex: deselect + ? null + : (selectedTextItemIndex ?? this.selectedTextItemIndex), isTrayShown: deselect ? false : (isTrayShown ?? this.isTrayShown), - message: message ?? this.message, - currentPageName: clearCurrentPageName ? null : (currentPageName ?? this.currentPageName), + message: message ?? this.message, + currentPageName: clearCurrentPageName + ? null + : (currentPageName ?? this.currentPageName), + isDrawingMode: isDrawingMode ?? this.isDrawingMode, + currentDrawColor: currentDrawColor ?? this.currentDrawColor, + currentStrokeWidth: currentStrokeWidth ?? this.currentStrokeWidth, + currentBrushType: currentBrushType ?? this.currentBrushType, ); } + @override bool operator ==(Object other) => identical(this, other) || - other is CanvasState && - runtimeType == other.runtimeType && - textItems == other.textItems && - backgroundColor == other.backgroundColor && - backgroundImagePath == other.backgroundImagePath && - selectedTextItemIndex == other.selectedTextItemIndex && - history == other.history && - future == other.future && - isTrayShown == other.isTrayShown && - message == other.message && - currentPageName == other.currentPageName; + other is CanvasState && + runtimeType == other.runtimeType && + textItems == other.textItems && + drawPaths == other.drawPaths && + backgroundColor == other.backgroundColor && + backgroundImagePath == other.backgroundImagePath && + selectedTextItemIndex == other.selectedTextItemIndex && + isDrawingMode == other.isDrawingMode && + history == other.history && + future == other.future && + isTrayShown == other.isTrayShown && + message == other.message && + currentPageName == other.currentPageName && + currentDrawColor == other.currentDrawColor && + currentStrokeWidth == other.currentStrokeWidth && + currentBrushType == other.currentBrushType; + @override int get hashCode => textItems.hashCode ^ + drawPaths.hashCode ^ backgroundColor.hashCode ^ backgroundImagePath.hashCode ^ selectedTextItemIndex.hashCode ^ history.hashCode ^ future.hashCode ^ isTrayShown.hashCode ^ + isDrawingMode.hashCode ^ + currentDrawColor.hashCode ^ + currentStrokeWidth.hashCode ^ + currentBrushType.hashCode ^ message.hashCode ^ currentPageName.hashCode; -} \ No newline at end of file +} diff --git a/lib/models/draw_model.dart b/lib/models/draw_model.dart new file mode 100644 index 00000000..7b99716e --- /dev/null +++ b/lib/models/draw_model.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +enum BrushType { brush, marker, highlighter, pencil } + +class DrawingPoint { + final Offset offset; + final Paint paint; + final double pressure; + + DrawingPoint({ + required this.offset, + required this.paint, + this.pressure = 1.0, + }); +} + +class DrawPath { + final List points; + final Color color; + final double strokeWidth; + final StrokeCap strokeCap; + final bool isFill; + final BrushType brushType; + + DrawPath({ + required this.points, + required this.color, + required this.strokeWidth, + this.strokeCap = StrokeCap.round, + this.isFill = false, + this.brushType = BrushType.brush, + }); + + DrawPath copyWith({ + List? points, + Color? color, + double? strokeWidth, + StrokeCap? strokeCap, + BrushType? brushType, + }) { + return DrawPath( + points: points ?? this.points, + color: color ?? this.color, + strokeWidth: strokeWidth ?? this.strokeWidth, + strokeCap: strokeCap ?? this.strokeCap, + brushType: brushType ?? this.brushType, + ); + } + + Map toJson() { + return { + 'points': points + .map((point) => { + 'x': point.offset.dx, + 'y': point.offset.dy, + 'color': point.paint.color.toARGB32(), + 'strokeWidth': point.paint.strokeWidth, + 'pressure': point.pressure, + 'style': point.paint.style.index, // Save paint style + }) + .toList(), + 'color': color.toARGB32(), + 'strokeWidth': strokeWidth, + 'strokeCap': strokeCap.index, + 'isFill': isFill, + 'brushType': brushType.index, + }; + } + + factory DrawPath.fromJson(Map json) { + final List pointsList = json['points']; + + return DrawPath( + points: pointsList.map((pointJson) { + return DrawingPoint( + offset: Offset(pointJson['x'], pointJson['y']), + paint: Paint() + ..color = Color(pointJson['color']) + ..strokeWidth = pointJson['strokeWidth'] + ..strokeCap = StrokeCap.round + ..style = pointJson['style'] != null + ? PaintingStyle.values[pointJson['style']] + : PaintingStyle.stroke, // Default to stroke for compatibility + pressure: pointJson['pressure'] ?? 1.0, + ); + }).toList(), + color: Color(json['color']), + strokeWidth: json['strokeWidth'], + strokeCap: StrokeCap.values[json['strokeCap']], + isFill: json['isFill'] ?? false, + brushType: BrushType.values[json['brushType'] ?? 0], + ); + } +} diff --git a/lib/ui/screens/canvas_screen.dart b/lib/ui/screens/canvas_screen.dart index d8b8c266..07b56d2e 100644 --- a/lib/ui/screens/canvas_screen.dart +++ b/lib/ui/screens/canvas_screen.dart @@ -6,14 +6,15 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:texterra/models/text_item_model.dart'; import 'package:texterra/ui/screens/save_page_dialog.dart'; import 'package:texterra/ui/screens/saved_pages.dart'; +import '../../constants/color_constants.dart'; import '../../cubit/canvas_cubit.dart'; import '../../cubit/canvas_state.dart'; import '../widgets/editable_text_widget.dart'; import '../widgets/font_controls.dart'; import '../widgets/background_color_tray.dart'; import '../widgets/background_options_sheet.dart'; +import '../widgets/drawing_canvas.dart'; import '../../utils/custom_snackbar.dart'; -import '../../constants/color_constants.dart'; class CanvasScreen extends StatelessWidget { const CanvasScreen({super.key}); @@ -120,7 +121,8 @@ class CanvasScreen extends StatelessWidget { builder: (context, state) { return PopupMenuButton( tooltip: "More options", - icon: const Icon(Icons.more_vert, color: ColorConstants.uiIconBlack), + icon: const Icon(Icons.more_vert, + color: ColorConstants.uiIconBlack), color: ColorConstants.uiWhite, // White background for dropdown onSelected: (value) async { final cubit = context.read(); @@ -161,11 +163,13 @@ class CanvasScreen extends StatelessWidget { value: 'new_page', child: Row( children: [ - Icon(Icons.add, color: Colors.black54, size: 20), + Icon(Icons.add, + color: ColorConstants.uiIconBlack, size: 20), SizedBox(width: 12), Text( 'New Page', - style: TextStyle(color: Colors.black87), + style: TextStyle( + color: ColorConstants.dialogTextBlack87), ), ], ), @@ -175,11 +179,12 @@ class CanvasScreen extends StatelessWidget { child: Row( children: [ Icon(Icons.folder_open, - color: Colors.black54, size: 20), + color: ColorConstants.uiIconBlack, size: 20), SizedBox(width: 12), Text( 'Saved Pages', - style: TextStyle(color: Colors.black87), + style: TextStyle( + color: ColorConstants.dialogTextBlack87), ), ], ), @@ -192,7 +197,7 @@ class CanvasScreen extends StatelessWidget { state.currentPageName != null ? Icons.save : Icons.save_as, - color: Colors.black54, + color: ColorConstants.uiIconBlack, size: 20, ), const SizedBox(width: 12), @@ -200,7 +205,8 @@ class CanvasScreen extends StatelessWidget { state.currentPageName != null ? "Save '${state.currentPageName}'" : "Save Page", - style: const TextStyle(color: Colors.black87), + style: const TextStyle( + color: ColorConstants.dialogTextBlack87), ), ], ), @@ -222,21 +228,91 @@ class CanvasScreen extends StatelessWidget { }, builder: (context, state) { return GestureDetector( - onTap: () => context.read().deselectText(), + 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: [ - ...List.generate(state.textItems.length, (index) { - final textItem = state.textItems[index]; - final isSelected = state.selectedTextItemIndex == index; - return _DraggableText( - key: ValueKey('text_item_$index'), - index: index, - textItem: textItem, - isSelected: isSelected, - ); - }), + // 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), + ), + ], + ), + ), + ), ], ), ), @@ -253,7 +329,7 @@ class CanvasScreen extends StatelessWidget { borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( - color: Colors.black.withAlpha((0.05 * 255).toInt()), + color: ColorConstants.getBlackWithAlpha((0.05 * 255).toInt()), blurRadius: 10, offset: const Offset(0, -5), ), @@ -265,7 +341,7 @@ class CanvasScreen extends StatelessWidget { Visibility( visible: state.isTrayShown, child: Container( - color: Colors.white, + color: ColorConstants.dialogWhite, child: const BackgroundColorTray(), ), ), @@ -275,18 +351,138 @@ class CanvasScreen extends StatelessWidget { ); }, ), - floatingActionButton: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - ), - child: FloatingActionButton( - backgroundColor: Colors.white, - elevation: 0.5, - onPressed: () { - context.read().addText('New Text'); - }, - child: const Icon(Icons.add, color: Colors.black), - ), + 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'); + }, + ), + const Divider(), + // Drawing Option + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: ColorConstants.dialogGreen + .withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + 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) + 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 in drawing mode, add option to clear drawings + 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), + ), + ); + }, ), ); } @@ -304,8 +500,8 @@ class CanvasScreen extends StatelessWidget { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.black.withAlpha((0.1 * 255).toInt()), - Colors.black.withAlpha((0.2 * 255).toInt()), + ColorConstants.getBlackWithAlpha((0.1 * 255).toInt()), + ColorConstants.getBlackWithAlpha((0.2 * 255).toInt()), ], ), ); @@ -356,50 +552,48 @@ class _DraggableText extends StatefulWidget { class _DraggableTextState extends State<_DraggableText> with AutomaticKeepAliveClientMixin { - late Offset localPosition; + Offset? _startPosition; + Offset? _dragStartPosition; @override bool get wantKeepAlive => true; - @override - void initState() { - super.initState(); - localPosition = Offset(widget.textItem.x, widget.textItem.y); - } - - @override - void didUpdateWidget(covariant _DraggableText oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.textItem.x != widget.textItem.x || - oldWidget.textItem.y != widget.textItem.y) { - localPosition = Offset(widget.textItem.x, widget.textItem.y); - } - } - @override Widget build(BuildContext context) { super.build(context); - return Positioned( - left: localPosition.dx, - top: localPosition.dy, - child: GestureDetector( - onPanUpdate: (details) { - setState(() { - localPosition += details.delta; - }); - }, - onPanEnd: (_) { + return GestureDetector( + onTap: () { + // Select this text item when tapped + context.read().selectText(widget.index); + }, + onPanStart: (details) { + // Select this text item when starting to drag + context.read().selectText(widget.index); + _startPosition = Offset(widget.textItem.x, widget.textItem.y); + _dragStartPosition = details.localPosition; + }, + onPanUpdate: (details) { + if (_startPosition != null && _dragStartPosition != null) { + final delta = details.localPosition - _dragStartPosition!; + final newPosition = _startPosition! + delta; + + // Update position in real-time during drag context.read().moveText( widget.index, - localPosition.dx, - localPosition.dy, + newPosition.dx, + newPosition.dy, ); - }, - child: EditableTextWidget( - index: widget.index, - textItem: widget.textItem, - isSelected: widget.isSelected, - ), + } + }, + onPanEnd: (_) { + _startPosition = null; + _dragStartPosition = null; + }, + behavior: HitTestBehavior.opaque, + child: EditableTextWidget( + index: widget.index, + textItem: widget.textItem, + isSelected: widget.isSelected, ), ); } diff --git a/lib/ui/screens/save_page_dialog.dart b/lib/ui/screens/save_page_dialog.dart index 20e251f0..c7004697 100644 --- a/lib/ui/screens/save_page_dialog.dart +++ b/lib/ui/screens/save_page_dialog.dart @@ -93,7 +93,7 @@ class _SavePageDialogState extends State { try { await context .read() - .savePage(pageName, label: label, color: color.value); + .savePage(pageName, label: label, color: color.toARGB32()); if (mounted) { Navigator.of(context).pop(); diff --git a/lib/ui/screens/saved_pages.dart b/lib/ui/screens/saved_pages.dart index db72d5e8..e1be37b1 100644 --- a/lib/ui/screens/saved_pages.dart +++ b/lib/ui/screens/saved_pages.dart @@ -95,7 +95,8 @@ class _SavedPagesScreenState extends State { foregroundColor: ColorConstants.dialogTextBlack, elevation: 0.5, leading: IconButton( - icon: const Icon(Icons.arrow_back, color: ColorConstants.dialogTextBlack), + icon: const Icon(Icons.arrow_back, + color: ColorConstants.dialogTextBlack), onPressed: () => Navigator.of(context).pop(), ), actions: [ @@ -165,7 +166,8 @@ class _SavedPagesScreenState extends State { final pageName = preview['name'] as String; final textCount = preview['textCount'] as int? ?? 0; final backgroundColor = - preview['backgroundColor'] as Color? ?? ColorConstants.uiGrayMedium; + preview['backgroundColor'] as Color? ?? + ColorConstants.uiGrayMedium; final lastModified = preview['lastModified'] as DateTime; final label = (preview['label'] as String?) ?? ''; @@ -207,8 +209,8 @@ class _SavedPagesScreenState extends State { ) : Icon( Icons.description, - color: - ColorConstants.dialogWhite.withOpacity(0.8), + color: ColorConstants.dialogWhite + .withOpacity(0.8), size: 28, ), ), @@ -225,7 +227,8 @@ class _SavedPagesScreenState extends State { style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600, - color: ColorConstants.dialogTextBlack87, + color: + ColorConstants.dialogTextBlack87, ), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/ui/widgets/drawing/brush_size_preview_painter.dart b/lib/ui/widgets/drawing/brush_size_preview_painter.dart new file mode 100644 index 00000000..f80d6083 --- /dev/null +++ b/lib/ui/widgets/drawing/brush_size_preview_painter.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import '../../../constants/color_constants.dart'; + +// Painter for showing MS Paint style brush size preview +class BrushSizePreviewPainter extends CustomPainter { + final Color color; + final double minWidth; + final double maxWidth; + final double currentWidth; + + BrushSizePreviewPainter({ + required this.color, + required this.minWidth, + required this.maxWidth, + required this.currentWidth, + }); + + @override + void paint(Canvas canvas, Size size) { + final Paint paint = Paint() + ..color = color + ..strokeCap = StrokeCap.round; + + final double startY = size.height / 2; + final double startX = 4; + final double endX = size.width - 4; + + // Draw the horizontal line showing size progression + final Path path = Path(); + path.moveTo(startX, startY); + + // Create a line from thin to thick + for (double x = startX; x <= endX; x++) { + final double progress = (x - startX) / (endX - startX); + final double strokeWidth = minWidth + progress * (maxWidth - minWidth); + + paint.strokeWidth = strokeWidth; + canvas.drawCircle( + Offset(x, startY), + strokeWidth / 2, + paint, + ); + } + + // Draw the current size indicator + final double currentX = startX + + ((currentWidth - minWidth) / (maxWidth - minWidth)) * (endX - startX); + final Paint indicatorPaint = Paint() + ..color = ColorConstants.dialogButtonBlue + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0; + + canvas.drawCircle( + Offset(currentX, startY), + currentWidth / 2 + 3, + indicatorPaint, + ); + } + + @override + bool shouldRepaint(covariant BrushSizePreviewPainter oldDelegate) { + return oldDelegate.color != color || + oldDelegate.currentWidth != currentWidth; + } +} diff --git a/lib/ui/widgets/drawing/drawing.dart b/lib/ui/widgets/drawing/drawing.dart new file mode 100644 index 00000000..6edb7cbe --- /dev/null +++ b/lib/ui/widgets/drawing/drawing.dart @@ -0,0 +1,6 @@ +// Barrel export file for drawing components +// This allows importing all drawing widgets with a single import statement + +export 'drawing_painter.dart'; +export 'brush_size_preview_painter.dart'; +export 'drawing_tools_panel.dart'; diff --git a/lib/ui/widgets/drawing/drawing_painter.dart b/lib/ui/widgets/drawing/drawing_painter.dart new file mode 100644 index 00000000..2c18e626 --- /dev/null +++ b/lib/ui/widgets/drawing/drawing_painter.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import '../../../models/draw_model.dart'; + +class DrawingPainter extends CustomPainter { + final List paths; + + DrawingPainter({required this.paths}); + + @override + void paint(Canvas canvas, Size size) { + for (var path in paths) { + if (path.isFill) { + // Fill the entire visible canvas area + final paint = Paint() + ..color = path.color + ..style = PaintingStyle.fill; + + canvas.drawRect(Offset.zero & size, paint); + continue; // Skip the regular path drawing for fill operations + } + final points = path.points; + if (points.isEmpty) continue; + + final paint = points.first.paint; + + if (points.length == 1) { + // Draw a dot for single point + canvas.drawCircle(points.first.offset, paint.strokeWidth / 2, paint); + } else { + // Draw a path for multiple points + final drawPath = Path(); + drawPath.moveTo(points.first.offset.dx, points.first.offset.dy); + + for (int i = 1; i < points.length; i++) { + drawPath.lineTo(points[i].offset.dx, points[i].offset.dy); + } + + canvas.drawPath(drawPath, paint); + } + } + } + + @override + bool shouldRepaint(DrawingPainter oldDelegate) => true; +} diff --git a/lib/ui/widgets/drawing/drawing_tools_panel.dart b/lib/ui/widgets/drawing/drawing_tools_panel.dart new file mode 100644 index 00000000..cbe104cb --- /dev/null +++ b/lib/ui/widgets/drawing/drawing_tools_panel.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; +import 'package:flex_color_picker/flex_color_picker.dart'; +import '../../../constants/color_constants.dart'; + +class DrawingToolsPanel extends StatelessWidget { + final Color currentColor; + final double currentStrokeWidth; + final Function(Color) onColorChanged; + final Function(double) onStrokeWidthChanged; + + const DrawingToolsPanel({ + super.key, + required this.currentColor, + required this.currentStrokeWidth, + required this.onColorChanged, + required this.onStrokeWidthChanged, + }); + + void _showColorPicker(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext context) { + Color pickerColor = currentColor; + return AlertDialog( + title: const Text('Pick a color'), + content: SingleChildScrollView( + child: ColorPicker( + color: pickerColor, + onColorChanged: (Color color) { + pickerColor = color; + }, + pickerTypeLabels: const { + ColorPickerType.wheel: 'Color Ring', + }, + enableShadesSelection: true, + colorCodeHasColor: true, + pickersEnabled: const { + ColorPickerType.wheel: true, + ColorPickerType.accent: false, + ColorPickerType.primary: false, + ColorPickerType.custom: false, + ColorPickerType.customSecondary: false, + }, + ), + ), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + TextButton( + child: const Text('Select'), + onPressed: () { + onColorChanged(pickerColor); + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + // Define available colors for drawing + final colors = [ + ColorConstants.dialogTextBlack, // Black + ColorConstants.dialogWhite, // White + ColorConstants.dialogRed, // Red + ColorConstants.dialogButtonBlue, // Blue + ColorConstants.dialogGreen, // Green + ColorConstants.highlightYellow, // Yellow + ColorConstants.dialogPurple, // Purple + ColorConstants.dialogWarningOrange, // Orange + ]; + + return Container( + width: 120, // Increased width to accommodate brush size controls + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: ColorConstants.dialogWhite, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: ColorConstants.dialogTextBlack, + blurRadius: 10, + spreadRadius: 1, + offset: const Offset(0, 3), + ), + ], + border: Border.all(color: ColorConstants.gray300), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: ColorConstants.gray300, + borderRadius: BorderRadius.circular(10), + ), + ), + + // Color picker section + Text( + 'Color', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + const SizedBox(height: 8), + + // Color display box with color code + GestureDetector( + onTap: () => _showColorPicker(context), + child: Container( + width: 80, + height: 40, + decoration: BoxDecoration( + color: currentColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: ColorConstants.gray300, width: 1), + boxShadow: [ + BoxShadow( + color: ColorConstants.getBlackWithValues(alpha: 0.1), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Tap to pick', + style: TextStyle( + fontSize: 8, + color: currentColor.computeLuminance() > 0.5 + ? ColorConstants.dialogTextBlack + : ColorConstants.dialogWhite, + fontWeight: FontWeight.w500, + ), + ), + Text( + '#${currentColor.toARGB32().toRadixString(16).substring(2).toUpperCase()}', + style: TextStyle( + fontSize: 8, + color: currentColor.computeLuminance() > 0.5 + ? ColorConstants.uiIconBlack + : ColorConstants.dialogWhite.withValues(alpha: 0.7), + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 8), + + Material( + color: ColorConstants.transparent, + child: Wrap( + spacing: 8.0, // gap between adjacent chips + runSpacing: 8.0, // gap between lines + children: colors.map((color) { + final isSelected = currentColor.toARGB32() == color.toARGB32(); + return InkWell( + onTap: () { + onColorChanged(color); + }, + borderRadius: BorderRadius.circular(15), + child: Container( + width: 30, + height: 30, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: isSelected ? ColorConstants.dialogButtonBlue : ColorConstants.gray300, + width: isSelected ? 2 : 1, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: ColorConstants.dialogButtonBlue.withValues(alpha: 0.5), + blurRadius: 4, + spreadRadius: 2, + ), + ] + : [], + ), + child: ClipOval( + child: Container( + color: color, + ), + ), + ), + ); + }).toList(), + ), + ), + + const SizedBox(height: 16), + + // Brush size section + Text( + 'Size', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + const SizedBox(height: 8), + + // Size preview circle + Container( + width: 80, + height: 30, + decoration: BoxDecoration( + color: ColorConstants.gray50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: ColorConstants.gray200, width: 1), + ), + child: Center( + child: AnimatedContainer( + duration: Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + width: currentStrokeWidth.clamp(3.0, 20.0), + height: currentStrokeWidth.clamp(3.0, 20.0), + decoration: BoxDecoration( + color: currentColor, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: currentColor.withValues(alpha: 0.3), + blurRadius: 2, + spreadRadius: 1, + ), + ], + ), + ), + ), + ), + + const SizedBox(height: 8), + + // Size slider + Container( + width: 100, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: currentColor.withValues(alpha: 0.8), + inactiveTrackColor: ColorConstants.gray200, + thumbColor: currentColor, + trackHeight: 3.0, + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 8.0), + overlayShape: RoundSliderOverlayShape(overlayRadius: 16.0), + overlayColor: currentColor.withValues(alpha: 0.1), + ), + child: Slider( + value: currentStrokeWidth.clamp(1.0, 20.0), + min: 1.0, + max: 20.0, + divisions: 19, + onChanged: (value) { + onStrokeWidthChanged(value); + }, + ), + ), + ), + + const SizedBox(height: 4), + + // Size value display + Text( + '${currentStrokeWidth.round()}px', + style: TextStyle( + fontSize: 10, + color: ColorConstants.gray600, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/widgets/drawing_canvas.dart b/lib/ui/widgets/drawing_canvas.dart new file mode 100644 index 00000000..1867b152 --- /dev/null +++ b/lib/ui/widgets/drawing_canvas.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import '../../models/draw_model.dart'; +import '../../constants/color_constants.dart'; +import 'drawing/drawing_painter.dart'; +import 'drawing/drawing_tools_panel.dart'; + +class DrawingCanvas extends StatefulWidget { + final List paths; + final bool isDrawingMode; + final Color currentDrawColor; + final double currentStrokeWidth; + final Function(Offset) onStartDrawing; + final Function(Offset) onUpdateDrawing; + final Function() onEndDrawing; + final Function(Color) onColorChanged; + final Function(double) onStrokeWidthChanged; + final Function() onUndoDrawing; + final Function() onClearDrawing; + + const DrawingCanvas({ + super.key, + required this.paths, + required this.isDrawingMode, + required this.currentDrawColor, + required this.currentStrokeWidth, + required this.onStartDrawing, + required this.onUpdateDrawing, + required this.onEndDrawing, + required this.onColorChanged, + required this.onStrokeWidthChanged, + required this.onUndoDrawing, + required this.onClearDrawing, + }); + + @override + State createState() => _DrawingCanvasState(); +} + +class _DrawingCanvasState extends State { + // Position for the draggable toolbar + Offset _toolbarPosition = Offset(16, 100); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // Main Drawing Canvas + IgnorePointer( + ignoring: !widget.isDrawingMode, + child: GestureDetector( + onPanStart: (details) { + if (widget.isDrawingMode) { + widget.onStartDrawing(details.localPosition); + } + }, + onPanUpdate: (details) { + if (widget.isDrawingMode) { + widget.onUpdateDrawing(details.localPosition); + } + }, + onPanEnd: (_) { + if (widget.isDrawingMode) { + widget.onEndDrawing(); + } + }, + child: RepaintBoundary( + child: CustomPaint( + painter: DrawingPainter(paths: widget.paths), + size: Size.infinite, + ), + ), + ), + ), + + // Drawing Tools Panel (only visible in drawing mode) + if (widget.isDrawingMode) + Positioned( + left: _toolbarPosition.dx, + top: _toolbarPosition.dy, + child: IgnorePointer( + ignoring: false, // Always accept pointer events for the toolbar + child: Material( + color: ColorConstants.transparent, + child: LongPressDraggable( + data: "toolbarDrag", + delay: Duration( + milliseconds: + 500), // Require long press to start dragging + feedback: Material( + color: ColorConstants.transparent, + elevation: 8.0, + child: Opacity( + opacity: 0.85, + child: DrawingToolsPanel( + currentColor: widget.currentDrawColor, + currentStrokeWidth: widget.currentStrokeWidth, + onColorChanged: (_) {}, // No-op in feedback + onStrokeWidthChanged: (_) {}, // No-op in feedback + ), + ), + ), + childWhenDragging: const SizedBox.shrink(), + onDragEnd: (details) { + // Calculate new position + final RenderBox renderBox = + context.findRenderObject() as RenderBox; + final Offset localPosition = + renderBox.globalToLocal(details.offset); + + final screenSize = MediaQuery.of(context).size; + double newX = localPosition.dx; + double newY = localPosition.dy; + + // Keep toolbar on screen + if (newX < 0) newX = 0; + if (newX > screenSize.width - 100) + newX = screenSize.width - 100; + if (newY < 0) newY = 0; + if (newY > screenSize.height - 300) + newY = screenSize.height - 300; + + setState(() { + _toolbarPosition = Offset(newX, newY); + }); + }, + child: DrawingToolsPanel( + currentColor: widget.currentDrawColor, + currentStrokeWidth: widget.currentStrokeWidth, + onColorChanged: widget.onColorChanged, + onStrokeWidthChanged: widget.onStrokeWidthChanged, + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 2526bb1e..da09048a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,7 +52,7 @@ dev_dependencies: # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. - flutter_lints: ^6.0.0 + flutter_lints: 5.0.0 flutter_launcher_icons: ^0.14.4 flutter_native_splash: ^2.3.5 # For information on the generic Dart part of this file, see the