Skip to content
Merged
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,14 @@ scripts/ Build & release scripts

Extensions are JavaScript packages that run inside a sandboxed JavaScriptCore context. Read the full guide at [dynamicisland.app/docs](https://dynamicisland.app/docs) or in [EXTENSIONS.md](EXTENSIONS.md).

## Appearance

Home slots, compact island size, animation intensity, and reduced motion can be configured in Settings. See [docs/APPEARANCE.md](docs/APPEARANCE.md).

## Calendar

The Calendar module supports account/source selection, holiday and birthday filters, duplicate collapse, and meeting-link actions. See [docs/CALENDAR.md](docs/CALENDAR.md).

## File Shelf

The built-in Shelf module can stage local files, folders, URLs, text snippets, and images from the island. See [docs/SHELF.md](docs/SHELF.md).
Expand Down
70 changes: 60 additions & 10 deletions SuperIsland/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,15 @@ final class AppState: ObservableObject {
// Appearance settings
@AppStorage("appearance.animationSpeed") var animationSpeed: Double = 1.0
@AppStorage("appearance.bounceAmount") var bounceAmount: Double = 0.25
@AppStorage("appearance.animationLevel") var animationLevelRaw = AnimationLevel.full.rawValue
@AppStorage("appearance.reduceMotion") var reduceMotion = false
@AppStorage("appearance.compactIslandWidth") var compactIslandWidth: Double = 200
@AppStorage("appearance.compactIslandHeight") var compactIslandHeight: Double = 36

@AppStorage("home.leadingPanel") var homeLeadingPanelRaw = HomePanel.nowPlaying.rawValue
@AppStorage("home.centerPanel") var homeCenterPanelRaw = HomePanel.calendar.rawValue
@AppStorage("home.trailingPanel") var homeTrailingPanelRaw = HomePanel.weather.rawValue

// General settings
@AppStorage("general.showMenuBarIcon") var showMenuBarIcon = true
@AppStorage("general.showOnAllSpaces") var showOnAllSpaces = true
Expand Down Expand Up @@ -300,13 +306,57 @@ final class AppState: ObservableObject {
/// Recomputed per-call so the live bounce-amount setting takes effect
/// without needing an app relaunch.
var notchAnimation: Animation {
.interactiveSpring(
duration: 0.5,
extraBounce: bounceAmount,
if shouldReduceMotion {
return .easeOut(duration: 0.14)
}

let level = animationLevel
let duration = 0.5 / max(0.5, animationSpeed)
let bounce = min(bounceAmount, level.bounceLimit)
return .interactiveSpring(
duration: duration,
extraBounce: bounce,
blendDuration: 0.125
)
}

var contentSwapAnimation: Animation {
shouldReduceMotion
? .easeOut(duration: 0.12)
: .smooth(duration: 0.22 / max(0.5, animationSpeed))
}

var hoverAnimation: Animation {
shouldReduceMotion
? .easeOut(duration: 0.08)
: .easeOut(duration: 0.18 / max(0.5, animationSpeed))
}

var shouldReduceMotion: Bool {
reduceMotion || animationLevel == .reduced
}

var animationLevel: AnimationLevel {
get { AnimationLevel(rawValue: animationLevelRaw) ?? .full }
set { animationLevelRaw = newValue.rawValue }
}

var homePanels: [HomePanel] {
let configured = [
HomePanel(rawValue: homeLeadingPanelRaw) ?? .nowPlaying,
HomePanel(rawValue: homeCenterPanelRaw) ?? .calendar,
HomePanel(rawValue: homeTrailingPanelRaw) ?? .weather
]

var seen = Set<HomePanel>()
return configured.filter { panel in
guard panel != .none else { return false }
guard seen.insert(panel).inserted else { return false }
guard let module = panel.module else { return false }
return isModuleEnabled(module)
}
}

// MARK: - State Transitions

func toggleExpansion() {
Expand Down Expand Up @@ -675,7 +725,7 @@ final class AppState: ObservableObject {
nextModule = modules[modules.count - 1]
}

withAnimation(Constants.contentSwap) {
withAnimation(contentSwapAnimation) {
previousModule = activeModule
activeModule = nextModule
}
Expand All @@ -689,14 +739,14 @@ final class AppState: ObservableObject {
if case .builtIn(let builtIn) = module, !isModuleEnabled(builtIn) {
return
}
withAnimation(Constants.contentSwap) {
withAnimation(contentSwapAnimation) {
previousModule = activeModule
activeModule = module
}
}

func selectFullExpandedTab(_ tab: FullExpandedTab) {
withAnimation(Constants.contentSwap) {
withAnimation(contentSwapAnimation) {
fullExpandedSelectedTab = tab
}

Expand All @@ -708,7 +758,7 @@ final class AppState: ObservableObject {
}

func showHomeTab() {
withAnimation(Constants.contentSwap) {
withAnimation(contentSwapAnimation) {
fullExpandedSelectedTab = .home
}
shelfDefaultToShelf = false
Expand Down Expand Up @@ -1197,7 +1247,7 @@ final class AppState: ObservableObject {
: (currentIndex - 1 + tabs.count) % tabs.count
let nextTab = tabs[nextIndex]

withAnimation(Constants.contentSwap) {
withAnimation(contentSwapAnimation) {
fullExpandedSelectedTab = nextTab
if case .module(let module) = nextTab {
previousModule = activeModule
Expand Down Expand Up @@ -1252,7 +1302,7 @@ final class AppState: ObservableObject {
activeModule = .builtIn(.shelf)
fullExpandedSelectedTab = .module(.builtIn(.shelf))

withAnimation(currentState == .compact ? notchAnimation : Constants.contentSwap) {
withAnimation(currentState == .compact ? notchAnimation : contentSwapAnimation) {
currentState = .fullExpanded
}
}
Expand All @@ -1269,7 +1319,7 @@ final class AppState: ObservableObject {
activeModule = .builtIn(.shelf)
fullExpandedSelectedTab = .module(.builtIn(.shelf))

withAnimation(currentState == .compact ? notchAnimation : Constants.contentSwap) {
withAnimation(currentState == .compact ? notchAnimation : contentSwapAnimation) {
currentState = .fullExpanded
}
}
Expand Down
61 changes: 61 additions & 0 deletions SuperIsland/Home/HomeLayoutPreferences.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import SwiftUI

enum HomePanel: String, CaseIterable, Identifiable {
case none
case nowPlaying
case calendar
case weather

var id: String { rawValue }

var title: String {
switch self {
case .none: return "Empty"
case .nowPlaying: return "Now Playing"
case .calendar: return "Calendar"
case .weather: return "Weather"
}
}

var iconName: String {
switch self {
case .none: return "minus.circle"
case .nowPlaying: return "music.note"
case .calendar: return "calendar"
case .weather: return "cloud.sun.fill"
}
}

var module: ModuleType? {
switch self {
case .none: return nil
case .nowPlaying: return .nowPlaying
case .calendar: return .calendar
case .weather: return .weather
}
}
}

enum AnimationLevel: String, CaseIterable, Identifiable {
case full
case subtle
case reduced

var id: String { rawValue }

var title: String {
switch self {
case .full: return "Full"
case .subtle: return "Subtle"
case .reduced: return "Reduced"
}
}

var bounceLimit: Double {
switch self {
case .full: return 0.5
case .subtle: return 0.08
case .reduced: return 0
}
}
}
30 changes: 30 additions & 0 deletions SuperIsland/Settings/AppearanceSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ struct AppearanceSettingsView: View {
// and for the @AppStorage initial values in AppState.
private enum Defaults {
static let bounceAmount: Double = 0.25
static let animationLevel = AnimationLevel.full
static let reduceMotion = false
static let compactIslandWidth: Double = 200
static let compactIslandHeight: Double = 36
}
Expand All @@ -17,9 +19,37 @@ struct AppearanceSettingsView: View {
section(
title: "Animation",
reset: {
appState.animationLevel = Defaults.animationLevel
appState.reduceMotion = Defaults.reduceMotion
appState.bounceAmount = Defaults.bounceAmount
}
) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Animation intensity").font(.system(size: 13))
Text("Controls island motion and transition strength")
.font(.system(size: 11)).foregroundColor(.secondary)
}
Spacer(minLength: 12)
Picker("", selection: $appState.animationLevelRaw) {
ForEach(AnimationLevel.allCases) { level in
Text(level.title).tag(level.rawValue)
}
}
.pickerStyle(.menu)
.labelsHidden()
.frame(width: 120)
}
.padding(.horizontal, 16).padding(.vertical, 12)

SettingRowDivider()
SettingToggleRow(
title: "Reduce motion",
description: "Simplify island transitions and content swaps",
isOn: $appState.reduceMotion
)

SettingRowDivider()
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Bounce").font(.system(size: 13))
Expand Down
28 changes: 28 additions & 0 deletions SuperIsland/Settings/ModuleSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ struct ModuleSettingsView: View {
SettingToggleRow(title: "Volume HUD", isOn: $appState.volumeHUDEnabled)
}

SettingSectionLabel(title: "Home")
SettingGroup {
homeSlotRow(title: "Left slot", selection: $appState.homeLeadingPanelRaw)
SettingRowDivider()
homeSlotRow(title: "Center slot", selection: $appState.homeCenterPanelRaw)
SettingRowDivider()
homeSlotRow(title: "Right slot", selection: $appState.homeTrailingPanelRaw)
}

SettingSectionLabel(title: "System")
SettingGroup {
SettingToggleRow(title: "Battery", isOn: $appState.batteryEnabled)
Expand Down Expand Up @@ -297,4 +306,23 @@ struct ModuleSettingsView: View {
.padding(.horizontal, 16)
.padding(.vertical, 11)
}

private func homeSlotRow(title: String, selection: Binding<String>) -> some View {
HStack {
Text(title)
.font(.system(size: 13))
Spacer(minLength: 12)
Picker("", selection: selection) {
ForEach(HomePanel.allCases) { panel in
Label(panel.title, systemImage: panel.iconName)
.tag(panel.rawValue)
}
}
.pickerStyle(.menu)
.labelsHidden()
.frame(width: 150)
}
.padding(.horizontal, 16)
.padding(.vertical, 11)
}
}
44 changes: 21 additions & 23 deletions SuperIsland/Views/HomeScreenView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,27 +26,20 @@ struct HomeScreenView: View {
}

private var visiblePanels: [HomePanel] {
var result: [HomePanel] = []
if appState.nowPlayingEnabled { result.append(.nowPlaying) }
if appState.calendarEnabled { result.append(.calendar) }
if appState.weatherEnabled { result.append(.weather) }
return result
appState.homePanels
}

private var threePanelLayout: some View {
HStack(alignment: .top, spacing: 14) {
HomeNowPlayingPanel()
.frame(width: 228, alignment: .topLeading)

homeDivider

HomeCalendarPanel()
.frame(maxWidth: .infinity, alignment: .topLeading)

homeDivider
ForEach(Array(visiblePanels.enumerated()), id: \.element.id) { index, panel in
panelView(for: panel)
.frame(width: preferredWidth(for: panel), alignment: .topLeading)
.frame(maxWidth: panel == .calendar ? .infinity : nil, alignment: .topLeading)

HomeWeatherPanel()
.frame(width: 150, alignment: .topLeading)
if index < visiblePanels.count - 1 {
homeDivider
}
}
}
}

Expand All @@ -66,6 +59,8 @@ struct HomeScreenView: View {
@ViewBuilder
private func panelView(for panel: HomePanel) -> some View {
switch panel {
case .none:
EmptyView()
case .nowPlaying:
HomeNowPlayingPanel()
case .calendar:
Expand All @@ -81,14 +76,17 @@ struct HomeScreenView: View {
.frame(width: 1)
.padding(.vertical, 4)
}
}

private enum HomePanel: String, Identifiable {
case nowPlaying
case calendar
case weather

var id: String { rawValue }
private func preferredWidth(for panel: HomePanel) -> CGFloat? {
switch panel {
case .nowPlaying:
return visiblePanels.count == 3 ? 228 : nil
case .weather:
return visiblePanels.count == 3 ? 150 : nil
case .calendar, .none:
return nil
}
}
}

private struct HomeNowPlayingPanel: View {
Expand Down
Loading