diff --git a/Rosetta/DesignSystem/Components/GlassModifier.swift b/Rosetta/DesignSystem/Components/GlassModifier.swift new file mode 100644 index 0000000..0a16bf4 --- /dev/null +++ b/Rosetta/DesignSystem/Components/GlassModifier.swift @@ -0,0 +1,81 @@ +import SwiftUI + +// MARK: - Glass Modifier (5-layer glass that works on black) +// +// Layer stack: +// 1. .ultraThinMaterial — system blur +// 2. black.opacity(0.22) — dark tint (depth on dark mode) +// 3. white→clear gradient — highlight / light refraction, blendMode(.screen) +// 4. double stroke — outer weak + inner stronger = glass edge +// 5. shadow — depth + +struct GlassModifier: ViewModifier { + let cornerRadius: CGFloat + + func body(content: Content) -> some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + + if #available(iOS 26, *) { + content + .background { + shape.fill(.clear) + .glassEffect(.regular, in: .rect(cornerRadius: cornerRadius)) + } + } else { + content + .background { + ZStack { + shape.fill(.ultraThinMaterial) + shape.fill(Color.black.opacity(0.22)) + shape.fill( + LinearGradient( + colors: [Color.white.opacity(0.14), .clear], + startPoint: .top, + endPoint: .bottom + ) + ).blendMode(.screen) + shape.stroke(Color.white.opacity(0.10), lineWidth: 1) + shape.stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5) + } + .shadow(color: Color.black.opacity(0.45), radius: 22, y: 14) + } + } + } +} + +// MARK: - View Extension + +extension View { + /// 5-layer frosted glass background. + func glass(cornerRadius: CGFloat = 24) -> some View { + modifier(GlassModifier(cornerRadius: cornerRadius)) + } + + /// Glass capsule — convenience for pill-shaped elements. + @ViewBuilder + func glassCapsule() -> some View { + if #available(iOS 26, *) { + background { + Capsule().fill(.clear) + .glassEffect(.regular, in: .capsule) + } + } else { + background { + ZStack { + Capsule().fill(.ultraThinMaterial) + Capsule().fill(Color.black.opacity(0.22)) + Capsule().fill( + LinearGradient( + colors: [Color.white.opacity(0.14), .clear], + startPoint: .top, + endPoint: .bottom + ) + ).blendMode(.screen) + Capsule().stroke(Color.white.opacity(0.10), lineWidth: 1) + Capsule().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5) + } + .shadow(color: Color.black.opacity(0.45), radius: 22, y: 14) + } + } + } +} diff --git a/Rosetta/DesignSystem/Components/RosettaTabBar.swift b/Rosetta/DesignSystem/Components/RosettaTabBar.swift index 761e0e1..4bac4ee 100644 --- a/Rosetta/DesignSystem/Components/RosettaTabBar.swift +++ b/Rosetta/DesignSystem/Components/RosettaTabBar.swift @@ -1,9 +1,24 @@ import SwiftUI import UIKit +// MARK: - Glass Effect ID Modifier (iOS 26+) + +private struct GlassEffectIDModifier: ViewModifier { + let id: String + let namespace: Namespace.ID? + + nonisolated func body(content: Content) -> some View { + if #available(iOS 26, *), let namespace { + content.glassEffectID(id, in: namespace) + } else { + content + } + } +} + // MARK: - Tab -enum RosettaTab: CaseIterable { +enum RosettaTab: CaseIterable, Sendable { case chats case settings case search @@ -41,26 +56,33 @@ struct TabBadge { } // MARK: - RosettaTabBar -/// Figma spec: -/// Container: padding(25h, 16t, 25b), gap=8 -/// Main pill: 282x62, r=296, padding(4h, 3v), glass+shadow -/// Each tab: 99x56, icon 30pt, label 10pt -/// Selected: #EDEDED rect r=100, icon+label #008BFF, label bold -/// Unselected: icon+label #404040 -/// Search pill: 62x62, glass+shadow, icon 17pt #404040 struct RosettaTabBar: View { let selectedTab: RosettaTab var onTabSelected: ((RosettaTab) -> Void)? var badges: [TabBadge] = [] + @Namespace private var glassNS + var body: some View { + if #available(iOS 26, *) { + GlassEffectContainer(spacing: 8) { + tabBarContent + } + .padding(.horizontal, 25) + .padding(.top, 4) + } else { + tabBarContent + .padding(.horizontal, 25) + .padding(.top, 4) + } + } + + private var tabBarContent: some View { HStack(spacing: 8) { mainTabsPill searchPill } - .padding(.horizontal, 25) - .padding(.top, 4) } } @@ -68,145 +90,314 @@ struct RosettaTabBar: View { private extension RosettaTabBar { var mainTabsPill: some View { + // Content on top — NOT clipped (lens can pop out) HStack(spacing: 0) { ForEach(RosettaTab.allCases.filter { $0 != .search }, id: \.self) { tab in - tabItem(tab) + TabItemButton( + tab: tab, + isSelected: tab == selectedTab, + badgeText: badges.first(where: { $0.tab == tab })?.text, + onTap: { onTabSelected?(tab) }, + glassNamespace: glassNS + ) } } .padding(.horizontal, 4) .padding(.top, 3) .padding(.bottom, 3) .frame(height: 62) - .applyGlassPill() + // Background clipped separately — content stays unclipped + .background { + mainPillGlass + } } - func tabItem(_ tab: RosettaTab) -> some View { - let isSelected = tab == selectedTab - let badgeText = badges.first(where: { $0.tab == tab })?.text - - return Button { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - onTabSelected?(tab) + @ViewBuilder + var mainPillGlass: some View { + if #available(iOS 26, *) { + Capsule().fill(.clear).glassEffect(.regular, in: .capsule) + } else { + ZStack { + // 1. Material + Capsule().fill(.ultraThinMaterial) + // 2. Dark tint + Capsule().fill(Color.black.opacity(0.22)) + // 3. Highlight + Capsule().fill( + LinearGradient( + colors: [Color.white.opacity(0.14), .clear], + startPoint: .top, + endPoint: .bottom + ) + ).blendMode(.screen) + // 4a. Outer stroke + Capsule().stroke(Color.white.opacity(0.10), lineWidth: 1) + // 4b. Inner stroke + Capsule().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5) } - } label: { + // 5. Shadows + .shadow(color: Color.black.opacity(0.45), radius: 22, y: 14) + } + } +} + +// MARK: - Tab Item Button + +private struct TabItemButton: View { + let tab: RosettaTab + let isSelected: Bool + let badgeText: String? + let onTap: () -> Void + var glassNamespace: Namespace.ID? + + @State private var pressed = false + + var body: some View { + Button(action: onTap) { VStack(spacing: 1) { ZStack(alignment: .topTrailing) { Image(systemName: isSelected ? tab.selectedIcon : tab.icon) .font(.system(size: 22)) - .foregroundStyle(isSelected ? RosettaColors.primaryBlue : RosettaColors.adaptive(light: Color(hex: 0x404040), dark: Color(hex: 0x8E8E93))) + .foregroundStyle(tabColor) .frame(height: 30) if let badgeText { - badgeView(badgeText) + Text(badgeText) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, badgeText.count > 2 ? 4 : 0) + .frame(minWidth: 18, minHeight: 18) + .background(Capsule().fill(RosettaColors.error)) + .offset(x: 10, y: -4) } } Text(tab.label) .font(.system(size: 10, weight: isSelected ? .bold : .medium)) - .foregroundStyle(isSelected ? RosettaColors.primaryBlue : RosettaColors.adaptive(light: Color(hex: 0x404040), dark: Color(hex: 0x8E8E93))) + .foregroundStyle(tabColor) } - .padding(.horizontal, 8) - .padding(.top, 6) - .padding(.bottom, 7) .frame(maxWidth: .infinity) .background { - if isSelected { + if isSelected && !pressed { RoundedRectangle(cornerRadius: 100) .fill(RosettaColors.adaptive( light: Color(hex: 0xEDEDED), dark: Color.white.opacity(0.12) )) + .padding(.horizontal, -8) + .padding(.vertical, -6) } } - .contentShape(Rectangle()) } .buttonStyle(.plain) + // Lens: padding → glass bubble → scale → lift + .padding(14) + .background { + if pressed { + lensBubble + .transition(.scale(scale: 0.8).combined(with: .opacity)) + } + } + .scaleEffect(pressed ? 1.12 : 1) + .offset(y: pressed ? -28 : 0) + .shadow(color: .black.opacity(pressed ? 0.45 : 0), radius: 22, y: 14) + .shadow(color: Color.cyan.opacity(pressed ? 0.12 : 0), radius: 20, y: 0) + .animation(.spring(response: 0.34, dampingFraction: 0.65), value: pressed) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if !pressed { + pressed = true + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + } + .onEnded { _ in pressed = false } + ) + .modifier(GlassEffectIDModifier(id: "\(tab)", namespace: glassNamespace)) .accessibilityLabel(tab.label) .accessibilityAddTraits(isSelected ? .isSelected : []) } + + private var tabColor: Color { + isSelected + ? RosettaColors.primaryBlue + : RosettaColors.adaptive( + light: Color(hex: 0x404040), + dark: Color(hex: 0x8E8E93) + ) + } + + // MARK: Lens Bubble + + @ViewBuilder + private var lensBubble: some View { + if #available(iOS 26, *) { + Circle() + .fill(.clear) + .glassEffect(.regular.interactive(), in: .circle) + } else { + ZStack { + // 1. Material + Circle().fill(.ultraThinMaterial) + // 2. Dark tint + Circle().fill(Color.black.opacity(0.22)) + // 3. Highlight (top→bottom, screen blend) + Circle().fill( + LinearGradient( + colors: [Color.white.opacity(0.14), .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ).blendMode(.screen) + // 4a. Outer stroke + Circle().stroke(Color.white.opacity(0.10), lineWidth: 1) + // 4b. Inner stroke + Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5) + // 6. Rainbow (thin, subtle, screen blend) + Circle().stroke( + AngularGradient( + colors: [ + Color.cyan.opacity(0.55), + Color.blue.opacity(0.55), + Color.purple.opacity(0.55), + Color.pink.opacity(0.55), + Color.orange.opacity(0.55), + Color.yellow.opacity(0.45), + Color.cyan.opacity(0.55), + ], + center: .center + ), + lineWidth: 1.4 + ).blendMode(.screen) + } + } + } } // MARK: - Search Pill private extension RosettaTabBar { var searchPill: some View { - let isSelected = selectedTab == .search + SearchPillButton( + isSelected: selectedTab == .search, + onTap: { onTabSelected?(.search) }, + glassNamespace: glassNS + ) + } +} - return Button { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - onTabSelected?(.search) - } - } label: { - Image(systemName: isSelected ? "magnifyingglass" : "magnifyingglass") +private struct SearchPillButton: View { + let isSelected: Bool + let onTap: () -> Void + var glassNamespace: Namespace.ID? + + @State private var pressed = false + + var body: some View { + Button(action: onTap) { + Image(systemName: "magnifyingglass") .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(isSelected ? RosettaColors.primaryBlue : RosettaColors.adaptive(light: Color(hex: 0x404040), dark: Color(hex: 0x8E8E93))) - .frame(width: 54, height: 54) - .background { - if isSelected { - Circle() - .fill(RosettaColors.adaptive( - light: Color(hex: 0xEDEDED), - dark: Color.white.opacity(0.12) - )) - } - } + .foregroundStyle( + isSelected + ? RosettaColors.primaryBlue + : RosettaColors.adaptive( + light: Color(hex: 0x404040), + dark: Color(hex: 0x8E8E93) + ) + ) } .buttonStyle(.plain) - .padding(4) + // Lens + .padding(14) + .background { + if pressed { + searchLensBubble + .transition(.scale(scale: 0.8).combined(with: .opacity)) + } + } + .scaleEffect(pressed ? 1.15 : 1) + .offset(y: pressed ? -28 : 0) + .shadow(color: .black.opacity(pressed ? 0.45 : 0), radius: 22, y: 14) + .shadow(color: Color.cyan.opacity(pressed ? 0.12 : 0), radius: 20, y: 0) + .animation(.spring(response: 0.34, dampingFraction: 0.65), value: pressed) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if !pressed { + pressed = true + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + } + .onEnded { _ in pressed = false } + ) .frame(width: 62, height: 62) - .applyGlassPill() + // Background clipped separately + .background { searchPillGlass } + .modifier(GlassEffectIDModifier(id: "search", namespace: glassNamespace)) .accessibilityLabel("Search") .accessibilityAddTraits(isSelected ? .isSelected : []) } -} -// MARK: - Glass Pill + // MARK: Lens for search -private struct GlassPillModifier: ViewModifier { - func body(content: Content) -> some View { + @ViewBuilder + private var searchLensBubble: some View { if #available(iOS 26, *) { - content - .glassEffect(.regular, in: .capsule) + Circle() + .fill(.clear) + .glassEffect(.regular.interactive(), in: .circle) } else { - content - .background( - Capsule() - .fill(RosettaColors.adaptive( - light: Color.white.opacity(0.65), - dark: Color(hex: 0x2A2A2A).opacity(0.8) - )) - .shadow(color: RosettaColors.adaptive( - light: Color(hex: 0xDDDDDD).opacity(0.5), - dark: Color.black.opacity(0.3) - ), radius: 16, y: 4) - ) + ZStack { + Circle().fill(.ultraThinMaterial) + Circle().fill(Color.black.opacity(0.22)) + Circle().fill( + LinearGradient( + colors: [Color.white.opacity(0.14), .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ).blendMode(.screen) + Circle().stroke(Color.white.opacity(0.10), lineWidth: 1) + Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5) + Circle().stroke( + AngularGradient( + colors: [ + Color.cyan.opacity(0.55), + Color.blue.opacity(0.55), + Color.purple.opacity(0.55), + Color.pink.opacity(0.55), + Color.orange.opacity(0.55), + Color.yellow.opacity(0.45), + Color.cyan.opacity(0.55), + ], + center: .center + ), + lineWidth: 1.4 + ).blendMode(.screen) + } } } -} -private extension View { - func applyGlassPill() -> some View { - modifier(GlassPillModifier()) - } -} - -// MARK: - Helpers - -private extension RosettaTabBar { - func badgeView(_ text: String) -> some View { - Text(text) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.white) - .padding(.horizontal, text.count > 2 ? 4 : 0) - .frame(minWidth: 18, minHeight: 18) - .background(Capsule().fill(RosettaColors.error)) - .offset(x: 10, y: -4) - } - - var safeAreaBottom: CGFloat { - guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = scene.windows.first(where: \.isKeyWindow) else { return 0 } - return window.safeAreaInsets.bottom + @ViewBuilder + private var searchPillGlass: some View { + if #available(iOS 26, *) { + Circle().fill(.clear).glassEffect(.regular, in: .circle) + } else { + ZStack { + Circle().fill(.ultraThinMaterial) + Circle().fill(Color.black.opacity(0.22)) + Circle().fill( + LinearGradient( + colors: [Color.white.opacity(0.14), .clear], + startPoint: .top, + endPoint: .bottom + ) + ).blendMode(.screen) + Circle().stroke(Color.white.opacity(0.10), lineWidth: 1) + Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5) + } + .shadow(color: Color.black.opacity(0.45), radius: 22, y: 14) + } } } @@ -214,12 +405,12 @@ private extension RosettaTabBar { #Preview { ZStack(alignment: .bottom) { - RosettaColors.Adaptive.background.ignoresSafeArea() + Color.black.ignoresSafeArea() VStack { Spacer() - Text("Content here") - .foregroundStyle(RosettaColors.Adaptive.text) + Text("Hold a tab to see the lens") + .foregroundStyle(.white.opacity(0.5)) Spacer() } diff --git a/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift b/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift index 587733e..5de8575 100644 --- a/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift +++ b/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift @@ -58,7 +58,7 @@ private extension ConfirmSeedPhraseView { Text("Enter words #2, #5, #9, #12 to confirm\nyou've backed up your phrase.") .font(.system(size: 15)) - .foregroundStyle(RosettaColors.secondaryText) + .foregroundStyle(Color.white.opacity(0.7)) .multilineTextAlignment(.center) .lineSpacing(3) } diff --git a/Rosetta/Features/Auth/ImportSeedPhraseView.swift b/Rosetta/Features/Auth/ImportSeedPhraseView.swift index 8444528..a720f8a 100644 --- a/Rosetta/Features/Auth/ImportSeedPhraseView.swift +++ b/Rosetta/Features/Auth/ImportSeedPhraseView.swift @@ -51,7 +51,7 @@ private extension ImportSeedPhraseView { Text("Enter your 12-word recovery phrase\nto restore your account.") .font(.system(size: 15)) - .foregroundStyle(RosettaColors.secondaryText) + .foregroundStyle(Color.white.opacity(0.7)) .multilineTextAlignment(.center) .lineSpacing(3) } diff --git a/Rosetta/Features/Auth/PasswordStrengthView.swift b/Rosetta/Features/Auth/PasswordStrengthView.swift index faabc15..013939a 100644 --- a/Rosetta/Features/Auth/PasswordStrengthView.swift +++ b/Rosetta/Features/Auth/PasswordStrengthView.swift @@ -107,7 +107,7 @@ struct WeakPasswordWarning: View { Text("Your password is too weak. Consider using at least 6 characters for better security.") .font(.system(size: 13)) - .foregroundStyle(RosettaColors.secondaryText) + .foregroundStyle(Color.white.opacity(0.7)) .lineSpacing(2) } .padding(14) diff --git a/Rosetta/Features/Auth/SeedPhraseView.swift b/Rosetta/Features/Auth/SeedPhraseView.swift index 01ff5e6..79310f2 100644 --- a/Rosetta/Features/Auth/SeedPhraseView.swift +++ b/Rosetta/Features/Auth/SeedPhraseView.swift @@ -54,7 +54,7 @@ private extension SeedPhraseView { Text("Write down these 12 words in order.\nYou'll need them to restore your account.") .font(.system(size: 15)) - .foregroundStyle(RosettaColors.secondaryText) + .foregroundStyle(Color.white.opacity(0.7)) .multilineTextAlignment(.center) .lineSpacing(3) .opacity(isContentVisible ? 1.0 : 0.0) diff --git a/Rosetta/Features/Auth/SetPasswordView.swift b/Rosetta/Features/Auth/SetPasswordView.swift index 6525c5c..8074df1 100644 --- a/Rosetta/Features/Auth/SetPasswordView.swift +++ b/Rosetta/Features/Auth/SetPasswordView.swift @@ -103,7 +103,7 @@ private extension SetPasswordView { ? "Set a password to protect your recovered account.\nYou'll need it to unlock Rosetta." : "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.") .font(.system(size: 14)) - .foregroundStyle(RosettaColors.secondaryText) + .foregroundStyle(Color.white.opacity(0.7)) .multilineTextAlignment(.center) .lineSpacing(2) } @@ -175,7 +175,7 @@ private extension SetPasswordView { Image(systemName: isRevealed ? "eye.slash" : "eye") .font(.system(size: 16)) - .foregroundStyle(RosettaColors.secondaryText) + .foregroundStyle(Color.white.opacity(0.5)) .frame(width: 30, height: 30) .contentShape(Rectangle()) .onTapGesture { @@ -218,7 +218,7 @@ private extension SetPasswordView { Text("Your password is never stored or sent anywhere. It's only used to encrypt your keys locally.") .font(.system(size: 13)) - .foregroundStyle(RosettaColors.secondaryText) + .foregroundStyle(Color.white.opacity(0.7)) .lineSpacing(2) } .padding(16) diff --git a/Rosetta/Features/Auth/UnlockView.swift b/Rosetta/Features/Auth/UnlockView.swift index 01f8f03..29af799 100644 --- a/Rosetta/Features/Auth/UnlockView.swift +++ b/Rosetta/Features/Auth/UnlockView.swift @@ -79,7 +79,7 @@ struct UnlockView: View { // Subtitle — matching Android Text("For unlock account enter password") .font(.system(size: 15)) - .foregroundStyle(RosettaColors.secondaryText) + .foregroundStyle(Color.white.opacity(0.7)) .opacity(showSubtitle ? 1 : 0) .offset(y: showSubtitle ? 0 : 8) @@ -136,7 +136,7 @@ private extension UnlockView { Image(systemName: showPassword ? "eye.slash" : "eye") .font(.system(size: 18)) - .foregroundStyle(Color(white: 0.45)) + .foregroundStyle(Color.white.opacity(0.5)) .contentShape(Rectangle()) .onTapGesture { showPassword.toggle() } } @@ -195,7 +195,7 @@ private extension UnlockView { VStack(spacing: 4) { HStack(spacing: 0) { Text("You can also ") - .foregroundStyle(RosettaColors.secondaryText) + .foregroundStyle(Color.white.opacity(0.7)) Button { onCreateNewAccount?() @@ -207,13 +207,13 @@ private extension UnlockView { .buttonStyle(.plain) Text(" or") - .foregroundStyle(RosettaColors.secondaryText) + .foregroundStyle(Color.white.opacity(0.7)) } .font(.system(size: 15)) HStack(spacing: 0) { Text("create a ") - .foregroundStyle(RosettaColors.secondaryText) + .foregroundStyle(Color.white.opacity(0.7)) Button { onCreateNewAccount?() diff --git a/Rosetta/Features/Auth/WelcomeView.swift b/Rosetta/Features/Auth/WelcomeView.swift index 56f0a39..0dd2ee0 100644 --- a/Rosetta/Features/Auth/WelcomeView.swift +++ b/Rosetta/Features/Auth/WelcomeView.swift @@ -90,7 +90,7 @@ private extension WelcomeView { var subtitleSection: some View { Text("Secure messaging with\ncryptographic keys") .font(.system(size: 16)) - .foregroundStyle(RosettaColors.secondaryText) + .foregroundStyle(Color.white.opacity(0.7)) .multilineTextAlignment(.center) .opacity(isVisible ? 1.0 : 0.0) .offset(y: isVisible ? 0 : 12) @@ -122,7 +122,7 @@ private extension WelcomeView { Text(label) .font(.system(size: 13, weight: .medium)) - .foregroundStyle(RosettaColors.secondaryText) + .foregroundStyle(Color.white.opacity(0.7)) } .accessibilityElement(children: .combine) .accessibilityLabel(label)