Mac-utlity-app / MUA / Views / SpaceLens / BubbleView.swift Blame
154 lines
6dacfa4 Mitchel Jan 17, 2026
//
//  BubbleView.swift
//  MUA
//
//  Created by Mitchel Volkering on 21/12/2025.
//
import SwiftUI
/// A single bubble representing a file or folder
struct BubbleView: View {
    let item: FileItem
    let size: CGFloat
    let isSelected: Bool
    let onTap: () -> Void
    let onDoubleTap: () -> Void
    @State private var isHovered = false
    @State private var isPressed = false
    private var bubbleScale: CGFloat {
        if isPressed { return 0.95 }
        if isHovered { return 1.03 }
        return 1.0
    }
    var body: some View {
        ZStack {
            // Glass bubble background
            Circle()
                .fill(
                    .linearGradient(
                        colors: [
                            item.color.opacity(0.7),
                            item.color.opacity(0.4)
                        ],
                        startPoint: .topLeading,
                        endPoint: .bottomTrailing
                    )
                )
                .overlay(
                    // Glass highlight
                    Circle()
                        .fill(
                            .linearGradient(
                                colors: [
                                    .white.opacity(0.4),
                                    .white.opacity(0.1),
                                    .clear
                                ],
                                startPoint: .topLeading,
                                endPoint: .center
                            )
                        )
                )
                .overlay(
                    // Glass edge
                    Circle()
                        .strokeBorder(
                            .linearGradient(
                                colors: [
                                    .white.opacity(0.6),
                                    .white.opacity(0.2),
                                    item.color.opacity(0.3)
                                ],
                                startPoint: .topLeading,
                                endPoint: .bottomTrailing
                            ),
                            lineWidth: isSelected ? 3 : 1.5
                        )
                )
                .shadow(color: item.color.opacity(0.3), radius: isHovered ? 15 : 8, y: 4)
                .shadow(color: .black.opacity(0.1), radius: 2, y: 1)
            // Content
            VStack(spacing: size > 80 ? 4 : 2) {
                if size > 50 {
                    Image(systemName: item.icon)
                        .font(.system(size: min(size * 0.25, 28)))
                        .foregroundStyle(.white)
                        .shadow(color: .black.opacity(0.2), radius: 1)
                }
                if size > 70 {
                    Text(item.name)
                        .font(.system(size: min(size * 0.1, 12), weight: .medium))
                        .foregroundStyle(.white)
                        .lineLimit(1)
                        .truncationMode(.middle)
                        .frame(maxWidth: size * 0.8)
                        .shadow(color: .black.opacity(0.3), radius: 1)
                }
                if size > 90 {
                    Text(item.formattedSize)
                        .font(.system(size: min(size * 0.08, 10), weight: .regular))
                        .foregroundStyle(.white.opacity(0.9))
                        .shadow(color: .black.opacity(0.2), radius: 1)
                }
            }
        }
        .frame(width: size, height: size)
        .scaleEffect(bubbleScale)
        .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovered)
        .animation(.spring(response: 0.2, dampingFraction: 0.8), value: isPressed)
        .onHover { hovering in
            isHovered = hovering
        }
        .onTapGesture(count: 2) {
            onDoubleTap()
        }
        .onTapGesture(count: 1) {
            withAnimation(.spring(response: 0.15)) {
                isPressed = true
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                isPressed = false
            }
            onTap()
        }
        .accessibilityElement()
        .accessibilityLabel("\(item.name), \(item.formattedSize)")
        .accessibilityHint(item.isDirectory ? "Double-tap to open folder" : "File")
    }
}
#Preview {
    HStack(spacing: 20) {
        BubbleView(
            item: FileItem(url: URL(fileURLWithPath: "/Applications"), isDirectory: true, size: 5_000_000_000),
            size: 120,
            isSelected: false,
            onTap: {},
            onDoubleTap: {}
        )
        BubbleView(
            item: FileItem(url: URL(fileURLWithPath: "/test.mp4"), isDirectory: false, size: 2_500_000_000),
            size: 100,
            isSelected: true,
            onTap: {},
            onDoubleTap: {}
        )
        BubbleView(
            item: FileItem(url: URL(fileURLWithPath: "/photo.jpg"), isDirectory: false, size: 500_000_000),
            size: 70,
            isSelected: false,
            onTap: {},
            onDoubleTap: {}
        )
    }
    .padding(40)
    .background(.black.opacity(0.9))
}