Skip to content

Commit 2db8cb0

Browse files
livitmomenbasel
authored andcommitted
feat: dashboard redesign with light/dark appearance toggle
Replaces the old Smart Scan landing with a storage-led dashboard: - circular gauge showing % of disk used; gradient turns orange/red once usage crosses 85% - free-space hero numerals with a stacked usage bar (used / junk / purgeable) and a legend - four stat tiles (Free Space, Junk Found, Apps, Purgeable) - contextual suggestion cards — surfaces the largest pending category and an FDA nudge when access is missing; nothing is shown when there are no real suggestions - same surface for scanning/completed/cleaning/cleaned states with an animated rotating gauge during scans Sidebar gets tinted icon tiles per row, capsule size badges per category, and a persistent system-health footer that turns orange when FDA isn't granted. Category detail pages get a hero card on top showing the tinted icon, description, item count + size, and the rescan action. The flat orange FDA bar is replaced with a gradient toast card that matches the dashboard surfaces. Toolbar gets a 3-segment appearance pill (system / light / dark) with an animated indicator that slides between segments — backed by a ThemeManager whose preference lives in AppStorage so it persists. docs/ui-prototype/{old,new}.html are clickable HTML mockups of the previous and proposed designs, included for design review.
1 parent 50a0adb commit 2db8cb0

9 files changed

Lines changed: 2061 additions & 321 deletions

File tree

PureMac.xcodeproj/project.pbxproj

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,16 @@
1515
47C5ECD49C4DD75F271DB6CE /* StringNormalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3AC5F17D7CA94FAB1E8D7A /* StringNormalization.swift */; };
1616
48D1431A7C99C19EEBDB056B /* CleaningEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8DE5D45BA19E2670B57DC5 /* CleaningEngine.swift */; };
1717
4F754D89F4CE5142BE384062 /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31F91226CDCFBB8E303B7DA /* AppConstants.swift */; };
18+
535B23C0108C06475215B8E8 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB003A7751F05727DBFD1A5 /* AppTheme.swift */; };
1819
75B5F0401D37F2872B1AD85A /* AppListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E6BC61614B27C065BB18C9 /* AppListView.swift */; };
1920
76B132F9C499225D33E0D075 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424F7B28624C271620E13BBC /* EmptyStateView.swift */; };
2021
826A750D2D7EC14C2AE306A3 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3667D46D8E2004EB4D73835A /* Models.swift */; };
2122
8947F0CE448791BD50EECF46 /* Conditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB94E06E145558123BB5BFB3 /* Conditions.swift */; };
2223
93743B036059418560D876E6 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A8D39C3C250F26302EC45AB /* SettingsView.swift */; };
24+
95278ABDCF5F4D6AC60503B1 /* AppearancePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92259B3E9F4468865F15DEEC /* AppearancePill.swift */; };
2325
9AA80E035DF7B33F6EE118DF /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED1B71D5F9510582E869CFD /* AppState.swift */; };
2426
9BB5AAA574AFED6C27A3F8E2 /* AppPathFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC0FEE7141871ED5F9E36121 /* AppPathFinder.swift */; };
25-
A89DF967EC9E5E8123B2925A /* SmartScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11BA091943263913276F3CCF /* SmartScanView.swift */; };
27+
A2AE68CC75CB72D6B10CBDF5 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F661A0F64CF93E482CB1728F /* DashboardView.swift */; };
2628
A9C3A1F643C26930F442E729 /* OrphanSafetyPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE790496C936424EF98320A /* OrphanSafetyPolicy.swift */; };
2729
B52938BBD11842631314543D /* CategoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10B0AF194677EAC1D5568785 /* CategoryDetailView.swift */; };
2830
BC6C800216343438413349A3 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4FD34378988D430A582ED0 /* OnboardingView.swift */; };
@@ -42,7 +44,7 @@
4244
01B2C5F66B6D812572BD4F05 /* CLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLI.swift; sourceTree = "<group>"; };
4345
02E502E2B5C6AECC76E5CFEF /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = "<group>"; };
4446
10B0AF194677EAC1D5568785 /* CategoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailView.swift; sourceTree = "<group>"; };
45-
11BA091943263913276F3CCF /* SmartScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartScanView.swift; sourceTree = "<group>"; };
47+
1AB003A7751F05727DBFD1A5 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
4648
1D3AC5F17D7CA94FAB1E8D7A /* StringNormalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringNormalization.swift; sourceTree = "<group>"; };
4749
23486A54A82EE3865B784D1C /* AppInfoFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoFetcher.swift; sourceTree = "<group>"; };
4850
2641C6376DD6F5889F35510E /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
@@ -63,6 +65,7 @@
6365
63581B70F9B10231964E3602 /* PureMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PureMacApp.swift; sourceTree = "<group>"; };
6466
77D3D9A9BC52839E6D0A22BC /* Locations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Locations.swift; sourceTree = "<group>"; };
6567
798B80977D14647A5691B0A0 /* AppFilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFilesView.swift; sourceTree = "<group>"; };
68+
92259B3E9F4468865F15DEEC /* AppearancePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePill.swift; sourceTree = "<group>"; };
6669
9F04B811BB0012F6D2F07F91 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
6770
9F510F232341EE18F11DC934 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = "<group>"; };
6871
A711CDF5285F68775D9B5513 /* ScanEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanEngine.swift; sourceTree = "<group>"; };
@@ -75,6 +78,7 @@
7578
E866A1541D289C69144A5E62 /* FullDiskAccessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullDiskAccessManager.swift; sourceTree = "<group>"; };
7679
EEF15CB1B8EFCA78EF491824 /* ScanError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanError.swift; sourceTree = "<group>"; };
7780
F31F91226CDCFBB8E303B7DA /* AppConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = "<group>"; };
81+
F661A0F64CF93E482CB1728F /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
7882
/* End PBXFileReference section */
7983

8084
/* Begin PBXGroup section */
@@ -107,9 +111,9 @@
107111
isa = PBXGroup;
108112
children = (
109113
10B0AF194677EAC1D5568785 /* CategoryDetailView.swift */,
114+
F661A0F64CF93E482CB1728F /* DashboardView.swift */,
110115
9F510F232341EE18F11DC934 /* MainWindow.swift */,
111116
3D4FD34378988D430A582ED0 /* OnboardingView.swift */,
112-
11BA091943263913276F3CCF /* SmartScanView.swift */,
113117
DAD2EC78EB98016ADA27D114 /* Apps */,
114118
8AE26715B5748307483A1A5E /* Components */,
115119
9D3C2DC382F5D4B77DFD218B /* Orphans */,
@@ -148,6 +152,8 @@
148152
8AE26715B5748307483A1A5E /* Components */ = {
149153
isa = PBXGroup;
150154
children = (
155+
92259B3E9F4468865F15DEEC /* AppearancePill.swift */,
156+
1AB003A7751F05727DBFD1A5 /* AppTheme.swift */,
151157
424F7B28624C271620E13BBC /* EmptyStateView.swift */,
152158
);
153159
path = Components;
@@ -201,11 +207,11 @@
201207
children = (
202208
B2EA41E1096FA8E3B916AD13 /* Assets.xcassets */,
203209
46660271CFF167AB0FE7371D /* Info.plist */,
210+
241E0895B09C71AB423B2F9E /* Localizable.strings */,
204211
5664D2BDAEAA9AE3A53DB364 /* PureMac.entitlements */,
205212
63581B70F9B10231964E3602 /* PureMacApp.swift */,
206213
D4333B07691BD85CAE0E5B15 /* Core */,
207214
7C1729F88C0E5563E1A3DB40 /* Extensions */,
208-
241E0895B09C71AB423B2F9E /* Localizable.strings */,
209215
F283C00EB52AB140F61500A3 /* Logic */,
210216
3CF46713F75B81F0F86D1C6F /* Models */,
211217
6184B2EC3D01E6E95633406E /* Services */,
@@ -273,6 +279,7 @@
273279
};
274280
};
275281
buildConfigurationList = 2ABAFAE07AA42044AE58F688 /* Build configuration list for PBXProject "PureMac" */;
282+
compatibilityVersion = "Xcode 14.0";
276283
developmentRegion = en;
277284
hasScannedForEncodings = 0;
278285
knownRegions = (
@@ -286,7 +293,6 @@
286293
mainGroup = 13CF0676D0E93925F46C13AA;
287294
minimizedProjectReferenceProxies = 1;
288295
preferredProjectObjectVersion = 77;
289-
productRefGroup = 4562CA9E5625FA4EEEFECB6D /* Products */;
290296
projectDirPath = "";
291297
projectRoot = "";
292298
targets = (
@@ -318,10 +324,13 @@
318324
75B5F0401D37F2872B1AD85A /* AppListView.swift in Sources */,
319325
9BB5AAA574AFED6C27A3F8E2 /* AppPathFinder.swift in Sources */,
320326
9AA80E035DF7B33F6EE118DF /* AppState.swift in Sources */,
327+
535B23C0108C06475215B8E8 /* AppTheme.swift in Sources */,
328+
95278ABDCF5F4D6AC60503B1 /* AppearancePill.swift in Sources */,
321329
F2FA881A4B2209CC2A6342FB /* CLI.swift in Sources */,
322330
B52938BBD11842631314543D /* CategoryDetailView.swift in Sources */,
323331
48D1431A7C99C19EEBDB056B /* CleaningEngine.swift in Sources */,
324332
8947F0CE448791BD50EECF46 /* Conditions.swift in Sources */,
333+
A2AE68CC75CB72D6B10CBDF5 /* DashboardView.swift in Sources */,
325334
76B132F9C499225D33E0D075 /* EmptyStateView.swift in Sources */,
326335
2253F11BDF561B617439C96B /* FullDiskAccessManager.swift in Sources */,
327336
E60B0A2C5D0A6CAE35BF4DFB /* Locations.swift in Sources */,
@@ -336,7 +345,6 @@
336345
D50EB059E741011EB2523731 /* ScanError.swift in Sources */,
337346
27F449EDD1B082FE11FEC9DF /* SchedulerService.swift in Sources */,
338347
93743B036059418560D876E6 /* SettingsView.swift in Sources */,
339-
A89DF967EC9E5E8123B2925A /* SmartScanView.swift in Sources */,
340348
47C5ECD49C4DD75F271DB6CE /* StringNormalization.swift in Sources */,
341349
340E424F759ACCDE7372F99F /* Theme.swift in Sources */,
342350
);

PureMac/Views/CategoryDetailView.swift

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,22 @@ struct CategoryDetailView: View {
1313
}
1414

1515
var body: some View {
16-
Group {
17-
if let result = result {
18-
if result.items.isEmpty {
19-
EmptyStateView("All Clean", systemImage: "checkmark.circle", description: "No junk files found in this category.")
16+
VStack(spacing: 0) {
17+
heroCard
18+
.padding(.horizontal, 20)
19+
.padding(.top, 16)
20+
.padding(.bottom, 12)
21+
22+
Group {
23+
if let result = result {
24+
if result.items.isEmpty {
25+
EmptyStateView("All Clean", systemImage: "checkmark.circle", description: "No junk files found in this category.")
26+
} else {
27+
fileList(result)
28+
}
2029
} else {
21-
fileList(result)
30+
EmptyStateView("Not Scanned", systemImage: category.icon, description: "Run a scan to analyze this category.", action: { appState.scanSingleCategory(category) }, actionLabel: "Scan Now")
2231
}
23-
} else {
24-
EmptyStateView("Not Scanned", systemImage: category.icon, description: "Run a scan to analyze this category.", action: { appState.scanSingleCategory(category) }, actionLabel: "Scan Now")
2532
}
2633
}
2734
.searchable(text: $searchText, prompt: "Filter files")
@@ -81,6 +88,47 @@ struct CategoryDetailView: View {
8188
}
8289
}
8390

91+
// MARK: - Hero
92+
93+
private var heroCard: some View {
94+
let totalSize = result?.totalSize ?? 0
95+
let itemCount = result?.itemCount ?? 0
96+
let isScanning = appState.scanState.isActive
97+
98+
return CardSurface(padding: 18) {
99+
HStack(alignment: .center, spacing: 16) {
100+
IconTile(systemName: category.icon, tint: category.color, size: 56, corner: 14)
101+
102+
VStack(alignment: .leading, spacing: 4) {
103+
Text(category.rawValue)
104+
.font(.system(size: 22, weight: .bold))
105+
Text(category.description)
106+
.font(.system(size: 12))
107+
.foregroundStyle(.secondary)
108+
if itemCount > 0 {
109+
Text("\(itemCount) items · \(ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file))")
110+
.font(.system(size: 11.5, weight: .medium))
111+
.foregroundStyle(category.color)
112+
.padding(.top, 2)
113+
}
114+
}
115+
116+
Spacer()
117+
118+
Button {
119+
appState.scanSingleCategory(category)
120+
} label: {
121+
Label(isScanning ? "Scanning…" : (result == nil ? "Scan" : "Rescan"),
122+
systemImage: "arrow.clockwise")
123+
.font(.system(size: 12.5, weight: .semibold))
124+
}
125+
.buttonStyle(.bordered)
126+
.controlSize(.large)
127+
.disabled(isScanning)
128+
}
129+
}
130+
}
131+
84132
// MARK: - File List
85133

86134
private func fileList(_ result: CategoryResult) -> some View {
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import SwiftUI
2+
3+
/// User-overridable appearance setting that lives independently of the system
4+
/// preference, mirroring the prototype's titlebar light/dark toggle.
5+
enum AppearanceMode: String, CaseIterable, Identifiable {
6+
case system, light, dark
7+
var id: String { rawValue }
8+
9+
var label: String {
10+
switch self {
11+
case .system: return "System"
12+
case .light: return "Light"
13+
case .dark: return "Dark"
14+
}
15+
}
16+
17+
var icon: String {
18+
switch self {
19+
case .system: return "circle.lefthalf.filled"
20+
case .light: return "sun.max.fill"
21+
case .dark: return "moon.fill"
22+
}
23+
}
24+
25+
var colorScheme: ColorScheme? {
26+
switch self {
27+
case .system: return nil
28+
case .light: return .light
29+
case .dark: return .dark
30+
}
31+
}
32+
}
33+
34+
@MainActor
35+
final class ThemeManager: ObservableObject {
36+
static let shared = ThemeManager()
37+
38+
@AppStorage("PureMac.Appearance") private var rawValue: String = AppearanceMode.system.rawValue
39+
40+
var appearance: AppearanceMode {
41+
get { AppearanceMode(rawValue: rawValue) ?? .system }
42+
set { rawValue = newValue.rawValue; objectWillChange.send() }
43+
}
44+
}
45+
46+
/// Centralized accent palette. Keeping these in one place lets the dashboard
47+
/// and sidebar share semantic tints (cleanup orange, performance green, etc.)
48+
/// instead of scattered Color literals.
49+
enum Tint {
50+
static let blue = Color(red: 0.04, green: 0.52, blue: 1.00)
51+
static let green = Color(red: 0.18, green: 0.78, blue: 0.47)
52+
static let orange = Color(red: 1.00, green: 0.62, blue: 0.04)
53+
static let purple = Color(red: 0.69, green: 0.32, blue: 0.87)
54+
static let pink = Color(red: 1.00, green: 0.30, blue: 0.50)
55+
static let cyan = Color(red: 0.30, green: 0.80, blue: 0.95)
56+
static let red = Color(red: 1.00, green: 0.27, blue: 0.23)
57+
static let yellow = Color(red: 1.00, green: 0.78, blue: 0.04)
58+
}
59+
60+
/// Tinted square icon container used in the sidebar and on dashboard cards.
61+
struct IconTile: View {
62+
let systemName: String
63+
var tint: Color = Tint.blue
64+
var size: CGFloat = 26
65+
var corner: CGFloat = 7
66+
67+
var body: some View {
68+
ZStack {
69+
RoundedRectangle(cornerRadius: corner, style: .continuous)
70+
.fill(tint.opacity(0.16))
71+
Image(systemName: systemName)
72+
.font(.system(size: size * 0.52, weight: .semibold))
73+
.foregroundStyle(tint)
74+
}
75+
.frame(width: size, height: size)
76+
}
77+
}
78+
79+
/// Card surface used on the dashboard, suggestion list, and detail pages.
80+
struct CardSurface<Content: View>: View {
81+
var padding: CGFloat = 16
82+
@ViewBuilder var content: Content
83+
84+
var body: some View {
85+
content
86+
.padding(padding)
87+
.background(
88+
RoundedRectangle(cornerRadius: 14, style: .continuous)
89+
.fill(Color(nsColor: .controlBackgroundColor))
90+
)
91+
.overlay(
92+
RoundedRectangle(cornerRadius: 14, style: .continuous)
93+
.strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5)
94+
)
95+
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
96+
}
97+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import SwiftUI
2+
3+
/// Inline 3-segment toggle (system / light / dark) with an animated active
4+
/// indicator that slides between segments. Replaces the SwiftUI `Menu` which
5+
/// looked like a generic dropdown affordance.
6+
struct AppearancePill: View {
7+
@Binding var selection: AppearanceMode
8+
@Namespace private var indicator
9+
10+
var body: some View {
11+
HStack(spacing: 2) {
12+
ForEach(AppearanceMode.allCases) { mode in
13+
Button {
14+
withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) {
15+
selection = mode
16+
}
17+
} label: {
18+
Image(systemName: mode.icon)
19+
.font(.system(size: 12, weight: .semibold))
20+
.frame(width: 28, height: 22)
21+
.foregroundStyle(selection == mode ? Color.primary : .secondary)
22+
.background(
23+
ZStack {
24+
if selection == mode {
25+
RoundedRectangle(cornerRadius: 6, style: .continuous)
26+
.fill(Color.primary.opacity(0.10))
27+
.matchedGeometryEffect(id: "indicator", in: indicator)
28+
}
29+
}
30+
)
31+
.contentShape(Rectangle())
32+
}
33+
.buttonStyle(.plain)
34+
.help(mode.label)
35+
}
36+
}
37+
}
38+
}

0 commit comments

Comments
 (0)