diff --git a/lib/cubit/canvas_cubit.dart b/lib/cubit/canvas_cubit.dart index 6e12edbd..7ce7f23c 100644 --- a/lib/cubit/canvas_cubit.dart +++ b/lib/cubit/canvas_cubit.dart @@ -14,12 +14,17 @@ import 'package:texterra/utils/custom_snackbar.dart'; import '../models/text_item_model.dart'; import '../models/draw_model.dart'; import 'canvas_state.dart'; +import '../utils/history_manager.dart'; +import 'dart:ui' as ui; class CanvasCubit extends Cubit { final ImagePicker _imagePicker = ImagePicker(); + final GlobalKey _canvasKey = GlobalKey(); // For thumbnail generation CanvasCubit() : super(CanvasState.initial()); + GlobalKey get canvasKey => _canvasKey; + //method to toggle the color tray void toggleTray() { emit(state.copyWith(isTrayShown: !state.isTrayShown)); @@ -169,7 +174,7 @@ class CanvasCubit extends Cubit { } // method to add the text - void addText(String text) { + void addText(String text) async { // Calculate offset based on the number of existing text items final offset = state.textItems.length * 20.0; // 20px offset for each item @@ -185,14 +190,29 @@ class CanvasCubit extends Cubit { color: ColorConstants.uiWhite, // My Default color for the text ); final updatedItems = List.from(state.textItems)..add(newTextItem); - emit( - state.copyWith( - textItems: updatedItems, - selectedTextItemIndex: updatedItems.length - 1, - history: [...state.history, state], - future: [], - ), + final oldState = state; + final newState = state.copyWith( + textItems: updatedItems, + selectedTextItemIndex: updatedItems.length - 1, + ); + + // Generate thumbnail + final thumbnail = await HistoryManager.generateThumbnail(_canvasKey, 200, 150); + + // Create history node + final nodeId = 'node_${DateTime.now().millisecondsSinceEpoch}'; + final historyNode = HistoryManager.createNode( + id: nodeId, + oldState: oldState, + newState: newState, + parentId: state.historyTree.currentNodeId, + thumbnail: thumbnail, ); + + // Add to history tree + final updatedTree = HistoryManager.addNodeToTree(state.historyTree, historyNode); + + emit(newState.copyWith(historyTree: updatedTree)); } // Method to toggle text highlighting @@ -437,48 +457,69 @@ class CanvasCubit extends Cubit { // method to undo changes and emit it void undo() { - if (state.history.isNotEmpty) { - final previousState = state.history.last; - final newHistory = List.from(state.history)..removeLast(); - emit(previousState.copyWith( - history: newHistory, - future: [state, ...state.future], - )); + if (state.historyTree.canUndo) { + final currentNode = state.historyTree.currentNode; + if (currentNode?.parentId != null) { + final parentNode = state.historyTree.nodes[currentNode!.parentId]; + if (parentNode != null) { + final newState = HistoryManager.jumpToNode(state, parentNode); + emit(newState); + } + } } } // method to redo changes and emit it void redo() { - if (state.future.isNotEmpty) { - final nextState = state.future.first; - final newFuture = List.from(state.future)..removeAt(0); - emit(nextState.copyWith( - future: newFuture, - history: [...state.history, state], - )); + if (state.historyTree.canRedo) { + final currentNode = state.historyTree.currentNode; + if (currentNode != null && currentNode.childIds.isNotEmpty) { + // For simplicity, redo to the first child + // In a more advanced implementation, you might want to track the "redo path" + final childNode = state.historyTree.nodes[currentNode.childIds.first]; + if (childNode != null) { + final newState = HistoryManager.jumpToNode(state, childNode); + emit(newState); + } + } } } // method to empty the canvas - void clearCanvas() { + void clearCanvas() async { // Delete the background image if it exists if (state.backgroundImagePath != null) { _deleteImageFile(state.backgroundImagePath!); } - 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, - ), + final oldState = state; + final newState = state.copyWith( + textItems: [], + drawPaths: [], + selectedTextItemIndex: null, + deselect: true, + isDrawingMode: false, // Exit drawing mode when clearing + clearCurrentPageName: true, + clearBackgroundImage: true, ); + + // Generate thumbnail + final thumbnail = await HistoryManager.generateThumbnail(_canvasKey, 200, 150); + + // Create history node + final nodeId = 'node_${DateTime.now().millisecondsSinceEpoch}'; + final historyNode = HistoryManager.createNode( + id: nodeId, + oldState: oldState, + newState: newState, + parentId: state.historyTree.currentNodeId, + thumbnail: thumbnail, + ); + + // Add to history tree + final updatedTree = HistoryManager.addNodeToTree(state.historyTree, historyNode); + + emit(newState.copyWith(historyTree: updatedTree)); CustomSnackbar.showInfo('Canvas cleared'); } @@ -487,26 +528,59 @@ class CanvasCubit extends Cubit { Color? backgroundColor, String? backgroundImagePath, bool clearBackgroundImage = false, - }) { + }) async { + final oldState = state; final newState = state.copyWith( textItems: textItems ?? state.textItems, backgroundColor: backgroundColor, backgroundImagePath: backgroundImagePath, clearBackgroundImage: clearBackgroundImage, - history: [...state.history, state], - future: [], ); - emit(newState); + + // Generate thumbnail for the new state + final thumbnail = await HistoryManager.generateThumbnail(_canvasKey, 200, 150); + + // Create new history node + final nodeId = 'node_${DateTime.now().millisecondsSinceEpoch}'; + final historyNode = HistoryManager.createNode( + id: nodeId, + oldState: oldState, + newState: newState, + parentId: state.historyTree.currentNodeId, + thumbnail: thumbnail, + ); + + // Add node to history tree + final updatedTree = HistoryManager.addNodeToTree(state.historyTree, historyNode); + + emit(newState.copyWith(historyTree: updatedTree)); } - void deleteText(int index) { + void deleteText(int index) async { final updatedList = List.from(state.textItems)..removeAt(index); - emit(state.copyWith( + final oldState = state; + final newState = state.copyWith( textItems: updatedList, selectedTextItemIndex: null, - history: [...state.history, state], - future: [], - deselect: true)); + deselect: true); + + // Generate thumbnail + final thumbnail = await HistoryManager.generateThumbnail(_canvasKey, 200, 150); + + // Create history node + final nodeId = 'node_${DateTime.now().millisecondsSinceEpoch}'; + final historyNode = HistoryManager.createNode( + id: nodeId, + oldState: oldState, + newState: newState, + parentId: state.historyTree.currentNodeId, + thumbnail: thumbnail, + ); + + // Add to history tree + final updatedTree = HistoryManager.addNodeToTree(state.historyTree, historyNode); + + emit(newState.copyWith(historyTree: updatedTree)); } Future savePage(String pageName, {String? label, int? color}) async { @@ -933,7 +1007,7 @@ class CanvasCubit extends Cubit { } // Add a new drawing path - void startNewDrawPath(Offset point) { + void startNewDrawPath(Offset point) async { if (!state.isDrawingMode) return; final paint = _createPaintForBrush( @@ -955,16 +1029,26 @@ class CanvasCubit extends Cubit { ); final newPaths = List.from(state.drawPaths)..add(newPath); + final oldState = state; + final newState = state.copyWith(drawPaths: newPaths); + + // Generate thumbnail + final thumbnail = await HistoryManager.generateThumbnail(_canvasKey, 200, 150); + + // Create history node + final nodeId = 'node_${DateTime.now().millisecondsSinceEpoch}'; + final historyNode = HistoryManager.createNode( + id: nodeId, + oldState: oldState, + newState: newState, + parentId: state.historyTree.currentNodeId, + thumbnail: thumbnail, + ); - // Save current state to history - final historyState = state.copyWith(); - final newHistory = List.from(state.history)..add(historyState); + // Add to history tree + final updatedTree = HistoryManager.addNodeToTree(state.historyTree, historyNode); - emit(state.copyWith( - drawPaths: newPaths, - history: newHistory, - future: [], // Clear future as we've made a new action - )); + emit(newState.copyWith(historyTree: updatedTree)); } // Update the current drawing path with a new point @@ -998,41 +1082,66 @@ class CanvasCubit extends Cubit { } // Clear all drawing paths - void clearDrawings() { + void clearDrawings() async { if (state.drawPaths.isEmpty) return; - // Save current state to history - final historyState = state.copyWith(); - final newHistory = List.from(state.history)..add(historyState); + final oldState = state; + final newState = state.copyWith(drawPaths: []); - emit(state.copyWith( - drawPaths: [], - history: newHistory, - future: [], // Clear future as we've made a new action - )); + // Generate thumbnail + final thumbnail = await HistoryManager.generateThumbnail(_canvasKey, 200, 150); + + // Create history node + final nodeId = 'node_${DateTime.now().millisecondsSinceEpoch}'; + final historyNode = HistoryManager.createNode( + id: nodeId, + oldState: oldState, + newState: newState, + parentId: state.historyTree.currentNodeId, + thumbnail: thumbnail, + ); + + // Add to history tree + final updatedTree = HistoryManager.addNodeToTree(state.historyTree, historyNode); + + emit(newState.copyWith(historyTree: updatedTree)); CustomSnackbar.showInfo('Drawings cleared'); } // Undo the last drawing stroke - void undoLastDrawing() { + void undoLastDrawing() async { 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 oldState = state; final newPaths = List.from(state.drawPaths); newPaths.removeLast(); + final newState = state.copyWith(drawPaths: newPaths); + + // Generate thumbnail + final thumbnail = await HistoryManager.generateThumbnail(_canvasKey, 200, 150); + + // Create history node + final nodeId = 'node_${DateTime.now().millisecondsSinceEpoch}'; + final historyNode = HistoryManager.createNode( + id: nodeId, + oldState: oldState, + newState: newState, + parentId: state.historyTree.currentNodeId, + thumbnail: thumbnail, + ); - emit(state.copyWith( - drawPaths: newPaths, - history: newHistory, - future: [], // Clear future as we've made a new action - )); + // Add to history node + final updatedTree = HistoryManager.addNodeToTree(state.historyTree, historyNode); + emit(newState.copyWith(historyTree: updatedTree)); CustomSnackbar.showInfo('Last stroke undone'); } + + /// Jump to a specific history node + void jumpToHistoryNode(HistoryNode node) { + final newState = HistoryManager.jumpToNode(state, node); + emit(newState); + } } // Enum for shadow presets diff --git a/lib/cubit/canvas_state.dart b/lib/cubit/canvas_state.dart index 2f581520..646d8c66 100644 --- a/lib/cubit/canvas_state.dart +++ b/lib/cubit/canvas_state.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; import '../models/text_item_model.dart'; import '../constants/color_constants.dart'; import '../models/draw_model.dart'; +import '../models/history_model.dart'; class CanvasState { final List textItems; final List drawPaths; - final List history; - final List future; + final HistoryTree historyTree; final Color backgroundColor; final String? backgroundImagePath; final int? selectedTextItemIndex; @@ -21,8 +21,7 @@ class CanvasState { const CanvasState({ required this.textItems, required this.drawPaths, - required this.history, - required this.future, + required this.historyTree, this.backgroundColor = ColorConstants.backgroundDarkGray, this.backgroundImagePath, this.selectedTextItemIndex, @@ -38,8 +37,7 @@ class CanvasState { return const CanvasState( textItems: [], drawPaths: [], - history: [], - future: [], + historyTree: HistoryTree.initial(), backgroundColor: ColorConstants.backgroundDarkGray, backgroundImagePath: null, selectedTextItemIndex: null, @@ -55,8 +53,7 @@ class CanvasState { CanvasState copyWith({ List? textItems, List? drawPaths, - List? history, - List? future, + HistoryTree? historyTree, Color? backgroundColor, String? backgroundImagePath, bool clearBackgroundImage = false, @@ -73,8 +70,7 @@ class CanvasState { return CanvasState( textItems: textItems ?? this.textItems, drawPaths: drawPaths ?? this.drawPaths, - history: history ?? this.history, - future: future ?? this.future, + historyTree: historyTree ?? this.historyTree, backgroundColor: backgroundColor ?? this.backgroundColor, backgroundImagePath: clearBackgroundImage ? null @@ -104,8 +100,7 @@ class CanvasState { backgroundImagePath == other.backgroundImagePath && selectedTextItemIndex == other.selectedTextItemIndex && isDrawingMode == other.isDrawingMode && - history == other.history && - future == other.future && + historyTree == other.historyTree && isTrayShown == other.isTrayShown && currentPageName == other.currentPageName && currentDrawColor == other.currentDrawColor && @@ -119,8 +114,7 @@ class CanvasState { backgroundColor.hashCode ^ backgroundImagePath.hashCode ^ selectedTextItemIndex.hashCode ^ - history.hashCode ^ - future.hashCode ^ + historyTree.hashCode ^ isTrayShown.hashCode ^ isDrawingMode.hashCode ^ currentDrawColor.hashCode ^ diff --git a/lib/models/history_model.dart b/lib/models/history_model.dart new file mode 100644 index 00000000..5713e3b7 --- /dev/null +++ b/lib/models/history_model.dart @@ -0,0 +1,135 @@ +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:equatable/equatable.dart'; + +/// Represents a node in the history tree +class HistoryNode extends Equatable { + final String id; + final DateTime timestamp; + final String description; + final ui.Image? thumbnail; + final Map stateDiff; // Only store changes, not full state + final List childIds; // For branching + final String? parentId; + + const HistoryNode({ + required this.id, + required this.timestamp, + required this.description, + this.thumbnail, + required this.stateDiff, + this.childIds = const [], + this.parentId, + }); + + HistoryNode copyWith({ + String? id, + DateTime? timestamp, + String? description, + ui.Image? thumbnail, + Map? stateDiff, + List? childIds, + String? parentId, + }) { + return HistoryNode( + id: id ?? this.id, + timestamp: timestamp ?? this.timestamp, + description: description ?? this.description, + thumbnail: thumbnail ?? this.thumbnail, + stateDiff: stateDiff ?? this.stateDiff, + childIds: childIds ?? this.childIds, + parentId: parentId ?? this.parentId, + ); + } + + @override + List get props => [id, timestamp, description, stateDiff, childIds, parentId]; +} + +/// Manages the history tree structure +class HistoryTree extends Equatable { + final Map nodes; + final String currentNodeId; + final String rootNodeId; + + const HistoryTree({ + required this.nodes, + required this.currentNodeId, + required this.rootNodeId, + }); + + HistoryTree.initial() + : nodes = { + 'root': HistoryNode( + id: 'root', + timestamp: DateTime.now(), + description: 'Initial state', + stateDiff: {}, + ) + }, + currentNodeId = 'root', + rootNodeId = 'root'; + + HistoryTree copyWith({ + Map? nodes, + String? currentNodeId, + String? rootNodeId, + }) { + return HistoryTree( + nodes: nodes ?? this.nodes, + currentNodeId: currentNodeId ?? this.currentNodeId, + rootNodeId: rootNodeId ?? this.rootNodeId, + ); + } + + /// Get the current node + HistoryNode? get currentNode => nodes[currentNodeId]; + + /// Get all nodes in chronological order (for timeline display) + List getTimelineNodes() { + final visited = {}; + final result = []; + + void traverse(String nodeId) { + if (visited.contains(nodeId)) return; + visited.add(nodeId); + + final node = nodes[nodeId]; + if (node != null) { + result.add(node); + for (final childId in node.childIds) { + traverse(childId); + } + } + } + + traverse(rootNodeId); + result.sort((a, b) => b.timestamp.compareTo(a.timestamp)); // Most recent first + return result; + } + + /// Check if we can undo + bool get canUndo => currentNode?.parentId != null; + + /// Check if we can redo + bool get canRedo => currentNode?.childIds.isNotEmpty ?? false; + + /// Get the path from root to current node + List getCurrentPath() { + final path = []; + String? currentId = currentNodeId; + + while (currentId != null) { + final node = nodes[currentId]; + if (node != null) { + path.insert(0, node); + } + currentId = node?.parentId; + } + + return path; + } + + @override + List get props => [nodes, currentNodeId, rootNodeId]; +} \ No newline at end of file diff --git a/lib/ui/screens/canvas_screen.dart b/lib/ui/screens/canvas_screen.dart index 195c5be4..29ac8d80 100644 --- a/lib/ui/screens/canvas_screen.dart +++ b/lib/ui/screens/canvas_screen.dart @@ -13,9 +13,7 @@ import '../widgets/text_box_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 '../../utils/web_utils.dart'; +import '../widgets/history_timeline.dart'; class CanvasScreen extends StatelessWidget { const CanvasScreen({super.key}); @@ -49,7 +47,7 @@ class CanvasScreen extends StatelessWidget { void _handleUndo(BuildContext context) { final cubit = context.read(); - if (cubit.state.history.isNotEmpty) { + if (cubit.state.historyTree.canUndo) { cubit.undo(); } else { CustomSnackbar.showInfo('Nothing to undo'); @@ -58,7 +56,7 @@ class CanvasScreen extends StatelessWidget { void _handleRedo(BuildContext context) { final cubit = context.read(); - if (cubit.state.future.isNotEmpty) { + if (cubit.state.historyTree.canRedo) { cubit.redo(); } else { CustomSnackbar.showInfo('Nothing to redo'); @@ -277,6 +275,7 @@ class CanvasScreen extends StatelessWidget { child: Stack( children: [ DrawingCanvas( + key: context.read().canvasKey, paths: state.drawPaths, isDrawingMode: state.isDrawingMode, currentDrawColor: state.currentDrawColor, @@ -350,6 +349,12 @@ class CanvasScreen extends StatelessWidget { ), ), ), + // History Timeline + Positioned( + top: 80, + right: 16, + child: const HistoryTimeline(), + ), ], ), ), diff --git a/lib/ui/widgets/drawing_canvas.dart b/lib/ui/widgets/drawing_canvas.dart index fbc513b8..aa588a60 100644 --- a/lib/ui/widgets/drawing_canvas.dart +++ b/lib/ui/widgets/drawing_canvas.dart @@ -18,6 +18,7 @@ class DrawingCanvas extends StatefulWidget { final Function(BrushType) onBrushTypeChanged; final Function() onUndoDrawing; final Function() onClearDrawing; + final GlobalKey? canvasKey; const DrawingCanvas({ super.key, @@ -34,6 +35,7 @@ class DrawingCanvas extends StatefulWidget { required this.onBrushTypeChanged, required this.onUndoDrawing, required this.onClearDrawing, + this.canvasKey, }); @override @@ -68,6 +70,7 @@ class _DrawingCanvasState extends State { } }, child: RepaintBoundary( + key: widget.canvasKey, child: CustomPaint( painter: DrawingPainter(paths: widget.paths), size: Size.infinite, diff --git a/lib/ui/widgets/history_timeline.dart b/lib/ui/widgets/history_timeline.dart new file mode 100644 index 00000000..a5b66969 --- /dev/null +++ b/lib/ui/widgets/history_timeline.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../cubit/canvas_cubit.dart'; +import '../../cubit/canvas_state.dart'; +import '../../models/history_model.dart'; +import 'dart:ui' as ui; + +class HistoryTimeline extends StatefulWidget { + const HistoryTimeline({Key? key}) : super(key: key); + + @override + State createState() => _HistoryTimelineState(); +} + +class _HistoryTimelineState extends State { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final timelineNodes = state.historyTree.getTimelineNodes(); + + if (timelineNodes.isEmpty) { + return const SizedBox.shrink(); + } + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: _isExpanded ? 300 : 60, + height: MediaQuery.of(context).size.height * 0.8, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.8), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white24, width: 1), + ), + child: Column( + children: [ + // Header + GestureDetector( + onTap: () => setState(() => _isExpanded = !_isExpanded), + child: Container( + height: 50, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Colors.white24)), + ), + child: Row( + children: [ + Icon( + _isExpanded ? Icons.history : Icons.history_toggle_off, + color: Colors.white, + size: 24, + ), + if (_isExpanded) ...[ + const SizedBox(width: 8), + const Text( + 'History', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Text( + '${timelineNodes.length} steps', + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + ), + ), + ], + ], + ), + ), + ), + + // Timeline + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: timelineNodes.length, + itemBuilder: (context, index) { + final node = timelineNodes[index]; + final isCurrent = node.id == state.historyTree.currentNodeId; + + return _HistoryNodeItem( + node: node, + isCurrent: isCurrent, + isExpanded: _isExpanded, + onTap: () => _jumpToNode(context, node), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } + + void _jumpToNode(BuildContext context, HistoryNode node) { + final cubit = context.read(); + cubit.jumpToHistoryNode(node); + } +} + +class _HistoryNodeItem extends StatelessWidget { + final HistoryNode node; + final bool isCurrent; + final bool isExpanded; + final VoidCallback onTap; + + const _HistoryNodeItem({ + required this.node, + required this.isCurrent, + required this.isExpanded, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isCurrent ? Colors.blue.withOpacity(0.3) : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isCurrent ? Colors.blue : Colors.white24, + width: isCurrent ? 2 : 1, + ), + ), + child: Row( + children: [ + // Thumbnail + Container( + width: 40, + height: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: Colors.grey[800], + ), + child: node.thumbnail != null + ? ClipRRect( + borderRadius: BorderRadius.circular(4), + child: RawImage( + image: node.thumbnail, + fit: BoxFit.cover, + ), + ) + : const Icon( + Icons.image, + color: Colors.white54, + size: 20, + ), + ), + + if (isExpanded) ...[ + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + node.description, + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + _formatTimestamp(node.timestamp), + style: const TextStyle( + color: Colors.white54, + fontSize: 10, + ), + ), + ], + ), + ), + ], + ], + ), + ), + ); + } + + String _formatTimestamp(DateTime timestamp) { + final now = DateTime.now(); + final difference = now.difference(timestamp); + + if (difference.inSeconds < 60) { + return 'Just now'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes}m ago'; + } else if (difference.inHours < 24) { + return '${difference.inHours}h ago'; + } else { + return '${difference.inDays}d ago'; + } + } +} \ No newline at end of file diff --git a/lib/utils/history_manager.dart b/lib/utils/history_manager.dart new file mode 100644 index 00000000..f988f1b8 --- /dev/null +++ b/lib/utils/history_manager.dart @@ -0,0 +1,314 @@ +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import '../models/history_model.dart'; +import '../cubit/canvas_state.dart'; +import '../models/draw_model.dart'; +import 'dart:math'; + +/// Utility class for managing history operations +class HistoryManager { + static const int maxHistorySize = 50; // Limit history size for performance + + /// Generate a thumbnail from a canvas widget + static Future generateThumbnail( + GlobalKey canvasKey, + double width, + double height, + ) async { + try { + final boundary = canvasKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + if (boundary == null) return null; + + final image = await boundary.toImage(pixelRatio: 0.5); + return image; + } catch (e) { + debugPrint('Error generating thumbnail: $e'); + return null; + } + } + + /// Calculate the diff between two canvas states + static Map calculateStateDiff(CanvasState oldState, CanvasState newState) { + final diff = {}; + + // Compare text items + if (oldState.textItems != newState.textItems) { + diff['textItems'] = _listDiff(oldState.textItems, newState.textItems); + } + + // Compare draw paths + if (oldState.drawPaths != newState.drawPaths) { + diff['drawPaths'] = _listDiff(oldState.drawPaths, newState.drawPaths); + } + + // Compare background color + if (oldState.backgroundColor != newState.backgroundColor) { + diff['backgroundColor'] = newState.backgroundColor.value; + } + + // Compare background image + if (oldState.backgroundImagePath != newState.backgroundImagePath) { + diff['backgroundImagePath'] = newState.backgroundImagePath; + } + + // Compare drawing mode settings + if (oldState.isDrawingMode != newState.isDrawingMode) { + diff['isDrawingMode'] = newState.isDrawingMode; + } + + if (oldState.currentDrawColor != newState.currentDrawColor) { + diff['currentDrawColor'] = newState.currentDrawColor.value; + } + + if (oldState.currentStrokeWidth != newState.currentStrokeWidth) { + diff['currentStrokeWidth'] = newState.currentStrokeWidth; + } + + if (oldState.currentBrushType != newState.currentBrushType) { + diff['currentBrushType'] = newState.currentBrushType.toString(); + } + + return diff; + } + + /// Apply a diff to a canvas state + static CanvasState applyDiff(CanvasState baseState, Map diff) { + return baseState.copyWith( + textItems: diff.containsKey('textItems') + ? _applyListDiff(baseState.textItems, diff['textItems']) + : null, + drawPaths: diff.containsKey('drawPaths') + ? _applyListDiff(baseState.drawPaths, diff['drawPaths']) + : null, + backgroundColor: diff.containsKey('backgroundColor') + ? Color(diff['backgroundColor']) + : null, + backgroundImagePath: diff['backgroundImagePath'], + isDrawingMode: diff['isDrawingMode'], + currentDrawColor: diff.containsKey('currentDrawColor') + ? Color(diff['currentDrawColor']) + : null, + currentStrokeWidth: diff['currentStrokeWidth']?.toDouble(), + currentBrushType: diff.containsKey('currentBrushType') + ? _parseBrushType(diff['currentBrushType']) + : null, + ); + } + + /// Generate a description for a state change + static String generateDescription(Map diff) { + final changes = []; + + if (diff.containsKey('textItems')) { + final textDiff = diff['textItems'] as Map; + if (textDiff['added']?.isNotEmpty ?? false) { + changes.add('Added ${textDiff['added'].length} text item(s)'); + } + if (textDiff['removed']?.isNotEmpty ?? false) { + changes.add('Removed ${textDiff['removed'].length} text item(s)'); + } + if (textDiff['modified']?.isNotEmpty ?? false) { + changes.add('Modified ${textDiff['modified'].length} text item(s)'); + } + } + + if (diff.containsKey('drawPaths')) { + final drawDiff = diff['drawPaths'] as Map; + if (drawDiff['added']?.isNotEmpty ?? false) { + changes.add('Added ${drawDiff['added'].length} drawing(s)'); + } + if (drawDiff['removed']?.isNotEmpty ?? false) { + changes.add('Removed ${drawDiff['removed'].length} drawing(s)'); + } + } + + if (diff.containsKey('backgroundColor')) { + changes.add('Changed background color'); + } + + if (diff.containsKey('backgroundImagePath')) { + changes.add('Changed background image'); + } + + if (diff.containsKey('isDrawingMode')) { + changes.add(diff['isDrawingMode'] ? 'Entered drawing mode' : 'Exited drawing mode'); + } + + return changes.isNotEmpty ? changes.join(', ') : 'Canvas modified'; + } + + /// Create a new history node + static HistoryNode createNode({ + required String id, + required CanvasState oldState, + required CanvasState newState, + required String parentId, + ui.Image? thumbnail, + }) { + final diff = calculateStateDiff(oldState, newState); + final description = generateDescription(diff); + + return HistoryNode( + id: id, + timestamp: DateTime.now(), + description: description, + thumbnail: thumbnail, + stateDiff: diff, + parentId: parentId, + ); + } + + /// Add a node to the history tree + static HistoryTree addNodeToTree(HistoryTree tree, HistoryNode node) { + final updatedNodes = Map.from(tree.nodes); + updatedNodes[node.id] = node; + + // Update parent's child list + if (node.parentId != null && updatedNodes.containsKey(node.parentId)) { + final parent = updatedNodes[node.parentId]!; + final updatedParent = parent.copyWith( + childIds: [...parent.childIds, node.id], + ); + updatedNodes[node.parentId!] = updatedParent; + } + + // Limit history size by removing old nodes + if (updatedNodes.length > maxHistorySize) { + _pruneOldNodes(updatedNodes, tree.rootNodeId); + } + + return tree.copyWith( + nodes: updatedNodes, + currentNodeId: node.id, + ); + } + + /// Jump to a specific node in history + static CanvasState jumpToNode(CanvasState currentState, HistoryNode targetNode) { + // Start from the root state and apply diffs along the path + CanvasState result = CanvasState.initial(); + + final path = _getPathToNode(currentState.historyTree, targetNode.id); + for (final node in path) { + result = applyDiff(result, node.stateDiff); + } + + return result.copyWith( + historyTree: currentState.historyTree.copyWith(currentNodeId: targetNode.id), + ); + } + + /// Get the path from root to a specific node + static List _getPathToNode(HistoryTree tree, String nodeId) { + final path = []; + String? currentId = nodeId; + + while (currentId != null && currentId != tree.rootNodeId) { + final node = tree.nodes[currentId]; + if (node != null) { + path.insert(0, node); + } + currentId = node?.parentId; + } + + return path; + } + + /// Prune old nodes to maintain history size limit + static void _pruneOldNodes(Map nodes, String rootId) { + // Simple pruning: remove nodes that are too far from current branches + // This is a basic implementation - could be made more sophisticated + final toRemove = []; + + for (final entry in nodes.entries) { + if (entry.key != rootId && !_isNodeReachable(nodes, rootId, entry.key)) { + toRemove.add(entry.key); + } + } + + for (final id in toRemove) { + nodes.remove(id); + } + } + + /// Check if a node is reachable from root + static bool _isNodeReachable(Map nodes, String rootId, String targetId) { + final visited = {}; + + bool traverse(String id) { + if (visited.contains(id)) return false; + visited.add(id); + + if (id == targetId) return true; + + final node = nodes[id]; + if (node == null) return false; + + for (final childId in node.childIds) { + if (traverse(childId)) return true; + } + + return false; + } + + return traverse(rootId); + } + + /// Helper method to calculate list differences + static Map _listDiff(List oldList, List newList) { + final added = []; + final removed = []; + final modified = []; + + // Simple diff - could be made more sophisticated + final maxLength = max(oldList.length, newList.length); + + for (int i = 0; i < maxLength; i++) { + if (i >= oldList.length) { + added.add(newList[i]); + } else if (i >= newList.length) { + removed.add(oldList[i]); + } else if (oldList[i] != newList[i]) { + modified.add(i); + } + } + + return { + 'added': added, + 'removed': removed, + 'modified': modified, + }; + } + + /// Apply list diff + static List _applyListDiff(List baseList, Map diff) { + final result = List.from(baseList); + + // This is a simplified implementation + // In a real scenario, you'd need more sophisticated merging logic + final added = diff['added'] as List? ?? []; + final removed = diff['removed'] as List? ?? []; + + result.addAll(added); + for (final item in removed) { + result.remove(item); + } + + return result; + } + + /// Parse brush type from string + static BrushType _parseBrushType(String typeString) { + switch (typeString) { + case 'BrushType.brush': + return BrushType.brush; + case 'BrushType.eraser': + return BrushType.eraser; + case 'BrushType.marker': + return BrushType.marker; + default: + return BrushType.brush; + } + } +} \ No newline at end of file diff --git a/test/history_test.dart b/test/history_test.dart new file mode 100644 index 00000000..3cd1cdee --- /dev/null +++ b/test/history_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:texterra/cubit/canvas_cubit.dart'; +import 'package:texterra/cubit/canvas_state.dart'; +import 'package:texterra/models/history_model.dart'; + +void main() { + group('History Tree Tests', () { + test('Initial state has root node', () { + final state = CanvasState.initial(); + expect(state.historyTree.nodes.containsKey('root'), true); + expect(state.historyTree.currentNodeId, 'root'); + }); + + test('History tree can add nodes', () { + final tree = HistoryTree.initial(); + final node = HistoryNode( + id: 'test_node', + timestamp: DateTime.now(), + description: 'Test change', + stateDiff: {'test': 'value'}, + parentId: 'root', + ); + + final updatedTree = HistoryManager.addNodeToTree(tree, node); + expect(updatedTree.nodes.containsKey('test_node'), true); + expect(updatedTree.nodes['root']?.childIds.contains('test_node'), true); + }); + + test('Can jump to history node', () { + final initialState = CanvasState.initial(); + final targetNode = HistoryNode( + id: 'target', + timestamp: DateTime.now(), + description: 'Target state', + stateDiff: {'textItems': []}, + parentId: 'root', + ); + + final result = HistoryManager.jumpToNode(initialState, targetNode); + expect(result.historyTree.currentNodeId, 'target'); + }); + }); +} \ No newline at end of file