diff --git a/SnapSort/Services/ScreenshotAnimation/ScreenshotAnimationManager.swift b/SnapSort/Services/ScreenshotAnimation/ScreenshotAnimationManager.swift index fa68523..3919f98 100644 --- a/SnapSort/Services/ScreenshotAnimation/ScreenshotAnimationManager.swift +++ b/SnapSort/Services/ScreenshotAnimation/ScreenshotAnimationManager.swift @@ -28,6 +28,9 @@ public final class ScreenshotAnimationManager: ObservableObject { /// Category assigned to the screenshot @Published public private(set) var category: String? + /// OCR progress [0, 1] + @Published public private(set) var progress: Double = 0 + // MARK: - Private Properties /// Animation window controller @@ -51,6 +54,7 @@ public final class ScreenshotAnimationManager: ObservableObject { // Load screenshot image if let image = NSImage(contentsOf: screenshotURL) { self.screenshot = image + self.progress = 0 self.state = .started // Create and show animation window @@ -59,7 +63,7 @@ public final class ScreenshotAnimationManager: ObservableObject { let hostingController = NSHostingController(rootView: contentView) let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 60, height: 60), + contentRect: NSRect(x: 0, y: 0, width: 76, height: 76), styleMask: [.borderless], backing: .buffered, defer: false @@ -80,7 +84,7 @@ public final class ScreenshotAnimationManager: ObservableObject { window.backgroundColor = NSColor.clear window.isOpaque = false window.level = .floating - window.hasShadow = false + window.hasShadow = true window.alphaValue = 0 window.isMovableByWindowBackground = true @@ -92,7 +96,7 @@ public final class ScreenshotAnimationManager: ObservableObject { // Fade in window if let window = self.windowController?.window { NSAnimationContext.runAnimationGroup { context in - context.duration = 0.3 + context.duration = 0.25 window.animator().alphaValue = 1 } } @@ -109,6 +113,7 @@ public final class ScreenshotAnimationManager: ObservableObject { private struct AnimationContentView: View { @ObservedObject var manager: ScreenshotAnimationManager @State private var opacity: Double = 0 + @State private var scale: CGFloat = 0.96 var body: some View { Group { @@ -116,28 +121,37 @@ public final class ScreenshotAnimationManager: ObservableObject { case .idle: EmptyView() case .started: - StartedAnimationView() + StartedAnimationView(progress: manager.progress) case .classifying: ClassifyingAnimationView() case .completed: CompletedAnimationView() } } - .frame(width: 60, height: 60) + .frame(width: 76, height: 76) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.primary.opacity(0.06), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.18), radius: 14, x: 0, y: 8) .opacity(opacity) + .scaleEffect(scale) .onAppear { - withAnimation(.easeIn(duration: 0.3)) { + withAnimation(.interpolatingSpring(mass: 0.7, stiffness: 140, damping: 16, initialVelocity: 0)) { opacity = 1 + scale = 1 } } .onChange(of: manager.state) { newState in if newState == .idle { - withAnimation(.easeOut(duration: 0.3)) { + withAnimation(.easeOut(duration: 0.25)) { opacity = 0 + scale = 0.98 } // Schedule actual window close after fade-out animation - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) { manager.windowController?.close() } } @@ -148,58 +162,101 @@ public final class ScreenshotAnimationManager: ObservableObject { // Animation views for each state private struct StartedAnimationView: View { + let progress: Double + @State private var animatedProgress: Double = 0 + var body: some View { ZStack { + // Track Circle() - .fill(.blue.opacity(0.15)) - .frame(width: 50, height: 50) - .opacity(0.7) - .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) + .stroke(Color.primary.opacity(0.12), style: StrokeStyle(lineWidth: 6, lineCap: .round)) + .padding(10) - Image(systemName: "photo.on.rectangle.angled") - .font(.system(size: 32, weight: .medium)) + // Progress ring + Circle() + .trim(from: 0, to: animatedProgress) + .stroke( + AngularGradient( + gradient: Gradient(colors: [Color.accentColor, Color.accentColor.opacity(0.7), Color.accentColor]), + center: .center + ), + style: StrokeStyle(lineWidth: 6, lineCap: .round, lineJoin: .round) + ) + .rotationEffect(.degrees(-90)) + .padding(10) + .animation(.interactiveSpring(response: 0.5, dampingFraction: 0.88, blendDuration: 0.2), value: animatedProgress) + + // Moving head highlight when not completed + if animatedProgress < 1.0 { + ArcHead() + .fill(Color.accentColor) + .frame(width: 6, height: 6) + .offset(y: -28) + .opacity(0.9) + } + + // Center symbol + Image(systemName: "text.magnifyingglass") + .font(.system(size: 22, weight: .semibold)) .symbolRenderingMode(.hierarchical) - .foregroundStyle(.blue) - .symbolEffect(.bounce.byLayer, options: .repeating) + .foregroundStyle(.primary.opacity(0.8)) + .transition(.scale.combined(with: .opacity)) + } + .onAppear { + animatedProgress = progress + } + .onChange(of: progress) { newValue in + animatedProgress = max(0, min(1, newValue)) + } + } + + // Small circular dot rendered as an arc end cap + private struct ArcHead: Shape { + func path(in rect: CGRect) -> Path { + var path = Path(ellipseIn: rect) + return path } } } private struct ClassifyingAnimationView: View { + @State private var isRotating: Bool = false + var body: some View { ZStack { Circle() - .fill(.orange.opacity(0.15)) - .frame(width: 50, height: 50) - .opacity(0.7) - .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) + .fill(Color.orange.opacity(0.12)) + .frame(width: 56, height: 56) Image(systemName: "folder.badge.gearshape") - .font(.system(size: 32, weight: .medium)) + .font(.system(size: 26, weight: .medium)) .symbolRenderingMode(.hierarchical) - .foregroundStyle(.orange) - .symbolEffect(.variableColor.iterative, options: .repeating) - .symbolEffect(.bounce.down.byLayer, options: .repeating) + .foregroundStyle(.primary.opacity(0.85)) + .rotationEffect(.degrees(isRotating ? 360 : 0)) + .animation(.linear(duration: 1.4).repeatForever(autoreverses: false), value: isRotating) } + .onAppear { isRotating = true } } } private struct CompletedAnimationView: View { + @State private var appear: Bool = false + var body: some View { ZStack { Circle() - .fill(.green.opacity(0.15)) - .frame(width: 50, height: 50) - .opacity(0.7) - .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) + .fill(Color.green.opacity(0.14)) + .frame(width: 56, height: 56) Image(systemName: "checkmark.circle.fill") - .font(.system(size: 32, weight: .medium)) + .font(.system(size: 30, weight: .semibold)) .symbolRenderingMode(.hierarchical) .foregroundStyle(.green) - .symbolEffect(.bounce.up, options: .nonRepeating) - .symbolEffect(.pulse, options: .nonRepeating) + .scaleEffect(appear ? 1 : 0.6) + .opacity(appear ? 1 : 0) + .animation(.interpolatingSpring(mass: 0.6, stiffness: 160, damping: 16, initialVelocity: 0), value: appear) } + .onAppear { appear = true } } } @@ -210,11 +267,20 @@ public final class ScreenshotAnimationManager: ObservableObject { } } + /// Update OCR progress [0,1]. If animation has not started, this will not present the window. + public func updateProgress(_ progress: Double) { + DispatchQueue.main.async { + let clamped = max(0, min(1, progress)) + self.progress = clamped + } + } + /// Update animation state to completed and close /// - Parameter category: Category assigned to the screenshot public func updateToCompleted(category: String) { DispatchQueue.main.async { self.category = category + self.progress = 1 self.state = .completed // Show completed animation for 1.5 seconds, then close @@ -235,15 +301,16 @@ public final class ScreenshotAnimationManager: ObservableObject { /// Close the animation window public func closeAnimation() { DispatchQueue.main.async { [weak self] in - withAnimation(.easeOut(duration: 0.3)) { + withAnimation(.easeOut(duration: 0.25)) { self?.state = .idle } // Clean up resources - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in self?.screenshot = nil self?.category = nil self?.windowController = nil + self?.progress = 0 } } } diff --git a/SnapSort/Services/ServiceManager.swift b/SnapSort/Services/ServiceManager.swift index 5540815..33ba030 100644 --- a/SnapSort/Services/ServiceManager.swift +++ b/SnapSort/Services/ServiceManager.swift @@ -251,7 +251,11 @@ public final class ServiceManager { let ocrResults = try await ocrProcessor.process( imagePath: url.path, - languages: [] + languages: [], + progressHandler: { [weak self] progress in + guard let self = self else { return } + self.animationManager.updateProgress(progress) + } ) // Get formatted text results