Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 100 additions & 33 deletions SnapSort/Services/ScreenshotAnimation/ScreenshotAnimationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
}
}
Expand All @@ -109,35 +113,45 @@ 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 {
switch manager.state {
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()
}
}
Expand All @@ -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 }
}
}

Expand All @@ -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
Expand All @@ -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
}
}
}
Expand Down
6 changes: 5 additions & 1 deletion SnapSort/Services/ServiceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down