mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Working LottieAnimations with Scaling and stuff
This commit is contained in:
parent
c00b6db984
commit
e500f5a5b2
13 changed files with 1451 additions and 1 deletions
427
DynamicIsland/components/Settings/AnimationEditorView.swift
Normal file
427
DynamicIsland/components/Settings/AnimationEditorView.swift
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
//
|
||||
// AnimationEditorView.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by AI Assistant on 11/10/2025.
|
||||
// Editor for resizing, repositioning, and cropping Lottie animations
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import LottieUI
|
||||
import Defaults
|
||||
|
||||
struct AnimationEditorView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
let sourceURL: URL
|
||||
let isRemoteURL: Bool
|
||||
@Binding var animation: CustomIdleAnimation?
|
||||
|
||||
@State private var name: String
|
||||
@State private var speed: CGFloat = 1.0
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var offsetX: CGFloat = 0
|
||||
@State private var offsetY: CGFloat = 0
|
||||
@State private var cropWidth: CGFloat = 30
|
||||
@State private var cropHeight: CGFloat = 20
|
||||
@State private var rotation: CGFloat = 0
|
||||
@State private var opacity: CGFloat = 1.0
|
||||
|
||||
@State private var showingError = false
|
||||
@State private var errorMessage = ""
|
||||
@State private var isImporting = false
|
||||
|
||||
// Preview state
|
||||
@State private var previewScale: CGFloat = 1.0
|
||||
|
||||
init(sourceURL: URL, isRemoteURL: Bool, animation: Binding<CustomIdleAnimation?>) {
|
||||
self.sourceURL = sourceURL
|
||||
self.isRemoteURL = isRemoteURL
|
||||
self._animation = animation
|
||||
|
||||
// Initialize name from URL
|
||||
let fileName = sourceURL.deletingPathExtension().lastPathComponent
|
||||
_name = State(initialValue: fileName)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Customize Animation")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
.padding()
|
||||
|
||||
Divider()
|
||||
|
||||
HSplitView {
|
||||
// Preview Panel
|
||||
VStack {
|
||||
Text("Preview")
|
||||
.font(.headline)
|
||||
.padding(.top)
|
||||
|
||||
ZStack {
|
||||
// Background to show bounds
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color.gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
|
||||
// Target size indicator (30x20 - actual size in notch)
|
||||
Rectangle()
|
||||
.strokeBorder(Color.blue.opacity(0.5), style: StrokeStyle(lineWidth: 2, dash: [5, 3]))
|
||||
.frame(width: 30 * previewScale, height: 20 * previewScale)
|
||||
|
||||
// Animation preview with transformations
|
||||
LottieView(state: LUStateData(
|
||||
type: .loadedFrom(sourceURL),
|
||||
speed: speed,
|
||||
loopMode: .loop
|
||||
))
|
||||
.frame(width: cropWidth * scale * previewScale, height: cropHeight * scale * previewScale)
|
||||
.offset(x: offsetX * previewScale, y: offsetY * previewScale)
|
||||
.rotationEffect(.degrees(rotation))
|
||||
.opacity(opacity)
|
||||
.clipped()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
|
||||
// Preview Scale Control
|
||||
HStack {
|
||||
Text("Preview Zoom:")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Slider(value: $previewScale, in: 1...10)
|
||||
.frame(width: 150)
|
||||
Text("\(Int(previewScale))x")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 30)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.frame(minWidth: 300)
|
||||
|
||||
Divider()
|
||||
|
||||
// Controls Panel
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Name
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Animation Name")
|
||||
.font(.headline)
|
||||
TextField("Name", text: $name)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Size Controls
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Size & Scale")
|
||||
.font(.headline)
|
||||
|
||||
// Scale
|
||||
HStack {
|
||||
Text("Scale:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $scale, in: 0.1...5.0)
|
||||
Text(String(format: "%.2f", scale))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Reset") {
|
||||
withAnimation { scale = 1.0 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
// Crop Width
|
||||
HStack {
|
||||
Text("Width:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $cropWidth, in: 5...100)
|
||||
Text(String(format: "%.0f", cropWidth))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("30") {
|
||||
withAnimation { cropWidth = 30 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
// Crop Height
|
||||
HStack {
|
||||
Text("Height:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $cropHeight, in: 5...100)
|
||||
Text(String(format: "%.0f", cropHeight))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("20") {
|
||||
withAnimation { cropHeight = 20 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Position Controls
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Position")
|
||||
.font(.headline)
|
||||
|
||||
// Offset X
|
||||
HStack {
|
||||
Text("Horizontal:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $offsetX, in: -50...50)
|
||||
Text(String(format: "%.1f", offsetX))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Center") {
|
||||
withAnimation { offsetX = 0 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
// Offset Y
|
||||
HStack {
|
||||
Text("Vertical:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $offsetY, in: -50...50)
|
||||
Text(String(format: "%.1f", offsetY))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Center") {
|
||||
withAnimation { offsetY = 0 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Rotation & Opacity
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Transform")
|
||||
.font(.headline)
|
||||
|
||||
// Rotation
|
||||
HStack {
|
||||
Text("Rotation:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $rotation, in: -180...180)
|
||||
Text(String(format: "%.0f°", rotation))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Reset") {
|
||||
withAnimation { rotation = 0 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
// Opacity
|
||||
HStack {
|
||||
Text("Opacity:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $opacity, in: 0...1)
|
||||
Text(String(format: "%.0f%%", opacity * 100))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("100%") {
|
||||
withAnimation { opacity = 1.0 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Animation Speed
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Animation")
|
||||
.font(.headline)
|
||||
|
||||
HStack {
|
||||
Text("Speed:")
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Slider(value: $speed, in: 0.1...3.0)
|
||||
Text(String(format: "%.2fx", speed))
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("1x") {
|
||||
withAnimation { speed = 1.0 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Presets
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Quick Presets")
|
||||
.font(.headline)
|
||||
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
||||
Button("Fit to Notch") {
|
||||
withAnimation {
|
||||
scale = 1.0
|
||||
cropWidth = 30
|
||||
cropHeight = 20
|
||||
offsetX = 0
|
||||
offsetY = 0
|
||||
rotation = 0
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Fill Notch") {
|
||||
withAnimation {
|
||||
scale = 1.5
|
||||
cropWidth = 30
|
||||
cropHeight = 20
|
||||
offsetX = 0
|
||||
offsetY = 0
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Scale Up 2x") {
|
||||
withAnimation {
|
||||
scale = 2.0
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Scale Down") {
|
||||
withAnimation {
|
||||
scale = 0.75
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Reset All") {
|
||||
withAnimation {
|
||||
scale = 1.0
|
||||
cropWidth = 30
|
||||
cropHeight = 20
|
||||
offsetX = 0
|
||||
offsetY = 0
|
||||
rotation = 0
|
||||
opacity = 1.0
|
||||
speed = 1.0
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(minWidth: 350, maxWidth: 450)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Footer with actions
|
||||
HStack {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Blue dashed box shows actual size in Dynamic Island")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Import") {
|
||||
importAnimation()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(name.isEmpty || isImporting)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(minWidth: 800, minHeight: 600)
|
||||
.alert("Import Error", isPresented: $showingError) {
|
||||
Button("OK") { }
|
||||
} message: {
|
||||
Text(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Import Logic
|
||||
|
||||
private func importAnimation() {
|
||||
guard !name.isEmpty else { return }
|
||||
|
||||
isImporting = true
|
||||
|
||||
// Create animation config with transform settings
|
||||
let config = AnimationTransformConfig(
|
||||
scale: scale,
|
||||
offsetX: offsetX,
|
||||
offsetY: offsetY,
|
||||
cropWidth: cropWidth,
|
||||
cropHeight: cropHeight,
|
||||
rotation: rotation,
|
||||
opacity: opacity
|
||||
)
|
||||
|
||||
// Import with config
|
||||
let result = IdleAnimationManager.shared.importLottieFile(
|
||||
from: sourceURL,
|
||||
name: name,
|
||||
speed: speed,
|
||||
transformConfig: config
|
||||
)
|
||||
|
||||
switch result {
|
||||
case .success(let importedAnimation):
|
||||
animation = importedAnimation
|
||||
dismiss()
|
||||
|
||||
case .failure(let error):
|
||||
errorMessage = error.localizedDescription
|
||||
showingError = true
|
||||
isImporting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview {
|
||||
AnimationEditorView(
|
||||
sourceURL: URL(string: "https://assets9.lottiefiles.com/packages/lf20_mniampqn.json")!,
|
||||
isRemoteURL: true,
|
||||
animation: .constant(nil)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
//
|
||||
// IdleAnimationsSettingsSection.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by AI Assistant on 11/10/2025.
|
||||
// Settings section for custom idle animations
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
import LottieUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct IdleAnimationsSettingsSection: View {
|
||||
@Default(.customIdleAnimations) var customIdleAnimations
|
||||
@Default(.selectedIdleAnimation) var selectedIdleAnimation
|
||||
@Default(.showNotHumanFace) var showNotHumanFace
|
||||
|
||||
@State private var showingFilePicker = false
|
||||
@State private var showingURLSheet = false
|
||||
@State private var urlInput = ""
|
||||
@State private var nameInput = ""
|
||||
@State private var selectedForDeletion: CustomIdleAnimation?
|
||||
@State private var showingDeleteAlert = false
|
||||
@State private var importError: String?
|
||||
@State private var showingError = false
|
||||
|
||||
// Animation editor state
|
||||
@State private var showingEditor = false
|
||||
@State private var editorSourceURL: URL?
|
||||
@State private var editorIsRemote = false
|
||||
@State private var editedAnimation: CustomIdleAnimation?
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
if showNotHumanFace {
|
||||
// Horizontal scrollable grid of animations
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack(spacing: 16) {
|
||||
ForEach(customIdleAnimations) { animation in
|
||||
AnimationPreviewCard(
|
||||
animation: animation,
|
||||
isSelected: selectedIdleAnimation?.id == animation.id,
|
||||
onSelect: {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
selectedIdleAnimation = animation
|
||||
}
|
||||
},
|
||||
onDelete: animation.isBuiltIn ? nil : {
|
||||
selectedForDeletion = animation
|
||||
showingDeleteAlert = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Add animation button
|
||||
AddAnimationCard {
|
||||
showingFilePicker = true
|
||||
} onAddURL: {
|
||||
urlInput = ""
|
||||
nameInput = ""
|
||||
showingURLSheet = true
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.frame(height: 140)
|
||||
} else {
|
||||
Text("Enable \"Show cool face animation while inactivity\" to customize animations")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Idle Animation Style")
|
||||
Spacer()
|
||||
if showNotHumanFace {
|
||||
Text("\(customIdleAnimations.count) animations")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
if showNotHumanFace {
|
||||
Text("Choose animation to display when Dynamic Island is idle. Tap to select, hold to delete custom animations.")
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showingFilePicker,
|
||||
allowedContentTypes: [UTType.json],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
handleFileImport(result)
|
||||
}
|
||||
.sheet(isPresented: $showingURLSheet) {
|
||||
URLImportSheet(
|
||||
urlInput: $urlInput,
|
||||
nameInput: $nameInput,
|
||||
onImport: { handleURLImport() },
|
||||
onCancel: { showingURLSheet = false }
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showingEditor) {
|
||||
if let url = editorSourceURL {
|
||||
AnimationEditorView(
|
||||
sourceURL: url,
|
||||
isRemoteURL: editorIsRemote,
|
||||
animation: $editedAnimation
|
||||
)
|
||||
.frame(minWidth: 800, minHeight: 600)
|
||||
.onChange(of: editedAnimation) { oldValue, newValue in
|
||||
if let animation = newValue {
|
||||
// Animation was successfully imported via editor
|
||||
withAnimation {
|
||||
selectedIdleAnimation = animation
|
||||
}
|
||||
showingEditor = false
|
||||
// Reset editor state
|
||||
editorSourceURL = nil
|
||||
editedAnimation = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Delete Animation", isPresented: $showingDeleteAlert) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Delete", role: .destructive) {
|
||||
if let animation = selectedForDeletion {
|
||||
IdleAnimationManager.shared.deleteAnimation(animation)
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
if let animation = selectedForDeletion {
|
||||
Text("Are you sure you want to delete \"\(animation.name)\"?")
|
||||
}
|
||||
}
|
||||
.alert("Import Error", isPresented: $showingError) {
|
||||
Button("OK") { }
|
||||
} message: {
|
||||
Text(importError ?? "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Import Handlers
|
||||
|
||||
private func handleFileImport(_ result: Result<[URL], Error>) {
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
guard let url = urls.first else { return }
|
||||
|
||||
// Show editor instead of directly importing
|
||||
editorSourceURL = url
|
||||
editorIsRemote = false
|
||||
editedAnimation = nil
|
||||
showingEditor = true
|
||||
|
||||
case .failure(let error):
|
||||
importError = error.localizedDescription
|
||||
showingError = true
|
||||
}
|
||||
}
|
||||
|
||||
private func handleURLImport() {
|
||||
guard let url = URL(string: urlInput), !nameInput.isEmpty else {
|
||||
importError = "Please provide both a valid URL and name"
|
||||
showingError = true
|
||||
return
|
||||
}
|
||||
|
||||
// Show editor instead of directly importing
|
||||
editorSourceURL = url
|
||||
editorIsRemote = true
|
||||
editedAnimation = nil
|
||||
showingURLSheet = false
|
||||
showingEditor = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animation Preview Card
|
||||
struct AnimationPreviewCard: View {
|
||||
let animation: CustomIdleAnimation
|
||||
let isSelected: Bool
|
||||
let onSelect: () -> Void
|
||||
let onDelete: (() -> Void)?
|
||||
|
||||
@State private var isHovering = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
// Preview area
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
isSelected ? Color.accentColor : Color.clear,
|
||||
lineWidth: 2.5
|
||||
)
|
||||
)
|
||||
|
||||
// Animation preview
|
||||
AnimationPreview(animation: animation)
|
||||
.frame(width: 60, height: 40)
|
||||
|
||||
// Delete button (only for custom animations)
|
||||
if let onDelete = onDelete, isHovering {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
onDelete()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.white, .red)
|
||||
.font(.system(size: 20))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(4)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 100, height: 80)
|
||||
.onHover { hovering in
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
|
||||
// Name and badge
|
||||
VStack(spacing: 4) {
|
||||
Text(animation.name)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(width: 100)
|
||||
|
||||
if isSelected {
|
||||
Text("Selected")
|
||||
.font(.system(size: 10))
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.accentColor)
|
||||
)
|
||||
} else if animation.isBuiltIn {
|
||||
Text("Built-in")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onSelect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animation Preview
|
||||
struct AnimationPreview: View {
|
||||
let animation: CustomIdleAnimation
|
||||
|
||||
var body: some View {
|
||||
switch animation.source {
|
||||
case .lottieFile(let url):
|
||||
LottieView(state: LUStateData(
|
||||
type: .loadedFrom(url),
|
||||
speed: animation.speed,
|
||||
loopMode: .loop
|
||||
))
|
||||
.frame(width: 60, height: 40)
|
||||
|
||||
case .lottieURL(let url):
|
||||
LottieView(state: LUStateData(
|
||||
type: .loadedFrom(url),
|
||||
speed: animation.speed,
|
||||
loopMode: .loop
|
||||
))
|
||||
.frame(width: 60, height: 40)
|
||||
|
||||
case .builtInFace:
|
||||
MinimalFaceFeatures(height: 40, width: 60)
|
||||
.scaleEffect(2.0) // Scale up the face for preview
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add Animation Card
|
||||
struct AddAnimationCard: View {
|
||||
let onAddFile: () -> Void
|
||||
let onAddURL: () -> Void
|
||||
|
||||
@State private var showingMenu = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Button {
|
||||
showingMenu.toggle()
|
||||
} label: {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [5, 3]))
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Add")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 100, height: 80)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.popover(isPresented: $showingMenu, arrowEdge: .bottom) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button {
|
||||
showingMenu = false
|
||||
onAddFile()
|
||||
} label: {
|
||||
Label("Import File...", systemImage: "doc.badge.plus")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(8)
|
||||
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
showingMenu = false
|
||||
onAddURL()
|
||||
} label: {
|
||||
Label("Add from URL...", systemImage: "link.badge.plus")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(8)
|
||||
}
|
||||
.frame(width: 180)
|
||||
}
|
||||
|
||||
Text("Add New")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URL Import Sheet
|
||||
struct URLImportSheet: View {
|
||||
@Binding var urlInput: String
|
||||
@Binding var nameInput: String
|
||||
let onImport: () -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Add Animation from URL")
|
||||
.font(.headline)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Lottie JSON URL")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("https://example.com/animation.json", text: $urlInput)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Animation Name")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("My Animation", text: $nameInput)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
onCancel()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Add") {
|
||||
onImport()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(urlInput.isEmpty || nameInput.isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 400)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview {
|
||||
Form {
|
||||
IdleAnimationsSettingsSection()
|
||||
}
|
||||
.frame(width: 600, height: 400)
|
||||
}
|
||||
|
|
@ -1227,6 +1227,9 @@ struct Appearance: View {
|
|||
Text("Additional features")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Custom Idle Animations Section
|
||||
IdleAnimationsSettingsSection()
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue