// // 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)) }