From 38668f5932a86c4f4d6965939ef23d72a349f3a9 Mon Sep 17 00:00:00 2001 From: useruserdev <256019073+useruserdev@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:24:54 +0500 Subject: [PATCH 1/2] Ignore .superpowers brainstorm workspace --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index fb2c5a3..07b4ccc 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ node_modules/ # internal process docs (not published) docs/superpowers/ +.superpowers/ From 4c94ff90795df1593364cf3751e95b3879bb21d8 Mon Sep 17 00:00:00 2001 From: useruserdev <256019073+useruserdev@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:35:09 +0500 Subject: [PATCH 2/2] Redesign iOS UI in Feather-style native language Full visual overhaul of the iOS app: - Two-tab shell (Extract / Settings) via native TabView; on iOS 26 SDK this renders the Liquid Glass floating tab bar automatically. - Configurable accent tint (8 colours, default indigo) and light/dark/system appearance, persisted in Settings. - Theme.swift: accent palette, per-protocol colours/badges, layout tokens, reusable components (GroupedCard, IconBadge, PrimaryButton, SectionLabel). - Extract screen: inset-grouped link card with paste action, accent CTA with loading state, calm empty/error states. - Results: new source subscription URL card with copy, plus a grouped config list with per-protocol coloured icons and tap-to-copy haptics. - Settings: grouped identity fields, accent grid picker, theme segmented control, About + GitHub links. - About: retinted grouped cards. - Bump MARKETING_VERSION to 1.0.1 (build 2). UI-only; extraction, crypto, network and parsing are unchanged. --- ios/happwn/Store/Settings.swift | 12 +- ios/happwn/UI/AboutView.swift | 92 ++++++++------- ios/happwn/UI/ContentView.swift | 55 --------- ios/happwn/UI/ExtractView.swift | 103 +++++++++++++++++ ios/happwn/UI/ResultsView.swift | 161 +++++++++++++++++++++----- ios/happwn/UI/RootView.swift | 24 ++++ ios/happwn/UI/SettingsView.swift | 84 +++++++++++--- ios/happwn/UI/Theme.swift | 188 +++++++++++++++++++++++++++++++ ios/happwn/happwnApp.swift | 4 +- ios/project.yml | 4 +- 10 files changed, 586 insertions(+), 141 deletions(-) delete mode 100644 ios/happwn/UI/ContentView.swift create mode 100644 ios/happwn/UI/ExtractView.swift create mode 100644 ios/happwn/UI/RootView.swift create mode 100644 ios/happwn/UI/Theme.swift diff --git a/ios/happwn/Store/Settings.swift b/ios/happwn/Store/Settings.swift index 5a73e50..51ffe12 100644 --- a/ios/happwn/Store/Settings.swift +++ b/ios/happwn/Store/Settings.swift @@ -1,7 +1,7 @@ import Foundation import Combine -/// User-editable request identity, persisted in UserDefaults. +/// User-editable request identity and appearance, persisted in UserDefaults. final class Settings: ObservableObject { @Published var userAgent: String { didSet { defaults.set(userAgent, forKey: Keys.userAgent) } @@ -9,17 +9,27 @@ final class Settings: ObservableObject { @Published var hwid: String { didSet { defaults.set(hwid, forKey: Keys.hwid) } } + @Published var accent: AppAccent { + didSet { defaults.set(accent.rawValue, forKey: Keys.accent) } + } + @Published var appearance: AppAppearance { + didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) } + } private let defaults: UserDefaults private enum Keys { static let userAgent = "happwn.userAgent" static let hwid = "happwn.hwid" + static let accent = "happwn.accent" + static let appearance = "happwn.appearance" } init(defaults: UserDefaults = .standard) { self.defaults = defaults self.userAgent = defaults.string(forKey: Keys.userAgent) ?? "Happ/1.0" self.hwid = defaults.string(forKey: Keys.hwid) ?? "" + self.accent = AppAccent(rawValue: defaults.string(forKey: Keys.accent) ?? "") ?? .indigo + self.appearance = AppAppearance(rawValue: defaults.string(forKey: Keys.appearance) ?? "") ?? .system } } diff --git a/ios/happwn/UI/AboutView.swift b/ios/happwn/UI/AboutView.swift index e446168..d36e326 100644 --- a/ios/happwn/UI/AboutView.swift +++ b/ios/happwn/UI/AboutView.swift @@ -11,66 +11,74 @@ struct AboutView: View { var body: some View { ScrollView { - VStack(spacing: 16) { - Image("AppLogo") - .resizable() - .scaledToFit() - .frame(width: 112, height: 112) - .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) - .shadow(color: .black.opacity(0.15), radius: 8, y: 4) - .padding(.top, 24) + VStack(spacing: 18) { + header - VStack(spacing: 4) { - Text("happwn").font(.largeTitle.bold()) - Text(version).font(.subheadline).foregroundColor(.secondary) - Text("Happ subscription config extractor") - .font(.callout).foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - - VStack(alignment: .leading, spacing: 12) { - section( - "What it does", - "Paste a happ:// link — happwn decrypts it, follows the embedded " - + "subscription URL with your User-Agent and X-HWID, and extracts every " - + "config (vless, vmess, trojan, ss, …) for copy and export." + VStack(alignment: .leading, spacing: 14) { + infoCard( + "Что делает", + "Вставь happ://-ссылку — happwn расшифрует её, перейдёт по встроенному " + + "URL подписки с твоими User-Agent и X-HWID и достанет каждый конфиг " + + "(vless, vmess, trojan, ss …) для копирования и экспорта." ) - section( - "Schemes", - "crypt, crypt2, crypt3, crypt4 (RSA PKCS#1 v1.5) and crypt5 " - + "(RSA → ChaCha20-Poly1305). Decryption runs fully on-device." + infoCard( + "Схемы", + "crypt, crypt2, crypt3, crypt4 (RSA PKCS#1 v1.5) и crypt5 " + + "(RSA → ChaCha20-Poly1305). Расшифровка целиком на устройстве." ) - section( - "Privacy", - "No analytics, no servers of our own. Requests go only to the " - + "subscription URL contained in your link." + infoCard( + "Приватность", + "Без аналитики и собственных серверов. Запросы идут только на URL " + + "подписки из твоей ссылки." ) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) Link(destination: repoURL) { Label("Source on GitHub", systemImage: "chevron.left.forwardslash.chevron.right") + .font(.headline) .frame(maxWidth: .infinity) + .padding(.vertical, 13) } .buttonStyle(.bordered) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) Text("Apache-2.0") - .font(.footnote).foregroundColor(.secondary) - .padding(.bottom, 24) + .font(.footnote).foregroundStyle(.secondary) + .padding(.bottom, 8) } - .padding(.horizontal) + .padding(Layout.screenPadding) } - .navigationTitle("About") + .background(Color(.systemGroupedBackground)) + .navigationTitle("О приложении") .navigationBarTitleDisplayMode(.inline) } - private func section(_ title: String, _ body: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title).font(.headline) - Text(body).font(.callout).foregroundColor(.secondary) + private var header: some View { + VStack(spacing: 8) { + Image("AppLogo") + .resizable() + .scaledToFit() + .frame(width: 104, height: 104) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .shadow(color: Color.accentColor.opacity(0.35), radius: 14, y: 6) + .padding(.top, 16) + + Text("happwn").font(.largeTitle.bold()) + Text(version).font(.subheadline).foregroundStyle(.secondary) + Text("Happ subscription config extractor") + .font(.callout).foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + + private func infoCard(_ title: String, _ body: String) -> some View { + GroupedCard { + VStack(alignment: .leading, spacing: 5) { + Text(title).font(.headline) + Text(body).font(.callout).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) } } } diff --git a/ios/happwn/UI/ContentView.swift b/ios/happwn/UI/ContentView.swift deleted file mode 100644 index 1807e09..0000000 --- a/ios/happwn/UI/ContentView.swift +++ /dev/null @@ -1,55 +0,0 @@ -import SwiftUI - -struct ContentView: View { - @EnvironmentObject private var settings: Settings - @StateObject private var vm = ExtractionViewModel() - - var body: some View { - NavigationView { - VStack(spacing: 16) { - TextField("happ://crypt…", text: $vm.link, axis: .vertical) - .textFieldStyle(.roundedBorder) - .lineLimit(3...6) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - - Button { - Task { await vm.extract(userAgent: settings.userAgent, hwid: settings.hwid) } - } label: { - Text("Извлечь").frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .disabled(vm.link.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - - content - Spacer() - } - .padding() - .navigationTitle("happwn") - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - NavigationLink { - SettingsView() - } label: { - Image(systemName: "gearshape") - } - } - } - } - } - - @ViewBuilder private var content: some View { - switch vm.state { - case .idle: - EmptyView() - case .loading: - ProgressView().padding() - case .success(let result): - ResultsView(result: result) - case .failure(let message): - Label(message, systemImage: "exclamationmark.triangle") - .foregroundColor(.red) - .multilineTextAlignment(.leading) - } - } -} diff --git a/ios/happwn/UI/ExtractView.swift b/ios/happwn/UI/ExtractView.swift new file mode 100644 index 0000000..bc00419 --- /dev/null +++ b/ios/happwn/UI/ExtractView.swift @@ -0,0 +1,103 @@ +import SwiftUI +import UIKit + +struct ExtractView: View { + @EnvironmentObject private var settings: Settings + @StateObject private var vm = ExtractionViewModel() + @FocusState private var fieldFocused: Bool + + private var isLoading: Bool { + if case .loading = vm.state { return true } + return false + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text("Вставь happ://-ссылку — расшифрую и достану конфиги") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + + linkSection + PrimaryButton(title: "Извлечь конфиги", isLoading: isLoading) { + fieldFocused = false + Task { await vm.extract(userAgent: settings.userAgent, hwid: settings.hwid) } + } + .disabled(vm.link.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading) + + content + } + .padding(Layout.screenPadding) + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("happwn") + } + + private var linkSection: some View { + VStack(alignment: .leading, spacing: Layout.rowSpacing) { + SectionLabel("Ссылка") + GroupedCard { + TextField("happ://crypt…", text: $vm.link, axis: .vertical) + .font(.system(.callout, design: .monospaced)) + .lineLimit(3...6) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .focused($fieldFocused) + .padding(14) + + Divider() + + Button { + if let s = UIPasteboard.general.string { vm.link = s } + } label: { + Label("Вставить из буфера", systemImage: "doc.on.clipboard") + .font(.callout.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } + } + } + } + + @ViewBuilder private var content: some View { + switch vm.state { + case .idle: + emptyState + case .loading: + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 40) + case .success(let result): + ResultsView(result: result) + case .failure(let message): + errorCard(message) + } + } + + private var emptyState: some View { + VStack(spacing: 10) { + Image(systemName: "arrow.down.doc") + .font(.system(size: 44, weight: .light)) + Text("Результат появится здесь") + .font(.subheadline) + } + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity) + .padding(.top, 48) + } + + private func errorCard(_ message: String) -> some View { + GroupedCard { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.title3) + Text(message) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(14) + } + } +} diff --git a/ios/happwn/UI/ResultsView.swift b/ios/happwn/UI/ResultsView.swift index 12b2ad6..01ef385 100644 --- a/ios/happwn/UI/ResultsView.swift +++ b/ios/happwn/UI/ResultsView.swift @@ -1,49 +1,156 @@ import SwiftUI +import UIKit struct ResultsView: View { let result: ExtractionViewModel.ExtractionResultView + @State private var copiedID: UUID? + @State private var sourceCopied = false + private var allConfigs: String { result.configs.map(\.uri).joined(separator: "\n") } var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("\(result.mode) · \(result.configs.count) конфигов") - .font(.subheadline).foregroundColor(.secondary) - Spacer() - if !result.configs.isEmpty { - Button { - UIPasteboard.general.string = allConfigs - } label: { - Label("Копировать всё", systemImage: "doc.on.doc") - } - .font(.subheadline) - ShareLink(item: allConfigs) { - Image(systemName: "square.and.arrow.up") - } - } + VStack(alignment: .leading, spacing: 18) { + if !result.source.isEmpty { + sourceSection } if let raw = result.rawBody { - Text("Не удалось распознать конфиги. Сырой ответ:") - .font(.footnote).foregroundColor(.secondary) - ScrollView { - Text(raw).font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } + rawSection(raw) } else { - List(result.configs) { config in - Text(config.uri) + configsSection + } + } + } + + // MARK: Source + + private var sourceSection: some View { + VStack(alignment: .leading, spacing: Layout.rowSpacing) { + SectionLabel("Источник подписки") + GroupedCard { + HStack(spacing: 12) { + IconBadge(systemName: "link", color: .accentColor) + Text(result.source) .font(.system(.caption, design: .monospaced)) .lineLimit(2) .textSelection(.enabled) - .onTapGesture { UIPasteboard.general.string = config.uri } + .frame(maxWidth: .infinity, alignment: .leading) + Button { + UIPasteboard.general.string = result.source + Haptics.tap() + sourceCopied = true + resetSourceCopied() + } label: { + Image(systemName: sourceCopied ? "checkmark" : "doc.on.doc") + .foregroundStyle(sourceCopied ? Color.green : Color.accentColor) + } + .buttonStyle(.plain) } - .listStyle(.plain) + .padding(14) } } } + + // MARK: Configs + + private var configsSection: some View { + VStack(alignment: .leading, spacing: Layout.rowSpacing) { + SectionLabel("Конфиги") { + HStack(spacing: 14) { + Text("\(result.mode) · \(result.configs.count)") + .font(.caption.weight(.bold)) + .foregroundStyle(Color.accentColor) + .padding(.horizontal, 9) + .padding(.vertical, 3) + .background(Color.accentColor.opacity(0.14), in: Capsule()) + if !result.configs.isEmpty { + Button { + UIPasteboard.general.string = allConfigs + Haptics.tap() + } label: { + Text("Копировать всё").font(.caption.weight(.semibold)) + } + ShareLink(item: allConfigs) { + Image(systemName: "square.and.arrow.up").font(.caption) + } + } + } + } + + GroupedCard { + ForEach(Array(result.configs.enumerated()), id: \.element.id) { index, config in + if index > 0 { + Divider().padding(.leading, 55) + } + configRow(config) + } + } + } + } + + private func configRow(_ config: ConfigEntry) -> some View { + Button { + UIPasteboard.general.string = config.uri + Haptics.tap() + copiedID = config.id + resetCopied(config.id) + } label: { + HStack(spacing: 12) { + IconBadge( + systemName: "", + color: ProtocolStyle.color(for: config.scheme), + text: ProtocolStyle.badge(for: config.scheme) + ) + Text(config.uri) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: copiedID == config.id ? "checkmark" : "doc.on.doc") + .font(.footnote) + .foregroundStyle(copiedID == config.id ? Color.green : Color.secondary) + } + .padding(14) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + // MARK: Raw fallback + + private func rawSection(_ raw: String) -> some View { + VStack(alignment: .leading, spacing: Layout.rowSpacing) { + SectionLabel("Сырой ответ") + Text("Не удалось распознать конфиги — показываю ответ как есть.") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + GroupedCard { + Text(raw) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + } + } + } + + // MARK: Transient copy feedback + + private func resetCopied(_ id: UUID) { + Task { + try? await Task.sleep(nanoseconds: 1_400_000_000) + if copiedID == id { copiedID = nil } + } + } + + private func resetSourceCopied() { + Task { + try? await Task.sleep(nanoseconds: 1_400_000_000) + sourceCopied = false + } + } } diff --git a/ios/happwn/UI/RootView.swift b/ios/happwn/UI/RootView.swift new file mode 100644 index 0000000..e9fb78d --- /dev/null +++ b/ios/happwn/UI/RootView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +/// Two-tab shell. On iOS 26 SDK the native TabView renders the Liquid Glass +/// floating tab bar automatically; on earlier systems it falls back to the +/// standard bar. +struct RootView: View { + var body: some View { + TabView { + NavigationStack { + ExtractView() + } + .tabItem { + Label("Извлечь", systemImage: "arrow.down.doc") + } + + NavigationStack { + SettingsView() + } + .tabItem { + Label("Настройки", systemImage: "gearshape") + } + } + } +} diff --git a/ios/happwn/UI/SettingsView.swift b/ios/happwn/UI/SettingsView.swift index 7c9bfa9..3ef7986 100644 --- a/ios/happwn/UI/SettingsView.swift +++ b/ios/happwn/UI/SettingsView.swift @@ -3,31 +3,89 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject private var settings: Settings + private let columns = [GridItem(.adaptive(minimum: 44), spacing: 14)] + var body: some View { Form { - Section("User-Agent") { - TextField("Happ/1.0", text: $settings.userAgent) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - } - Section("X-HWID") { - TextField("device id", text: $settings.hwid) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - } Section { + labeledField(icon: "ellipsis.curlybraces", tint: .indigo, + title: "User-Agent", placeholder: "Happ/1.0", + text: $settings.userAgent) + labeledField(icon: "iphone", tint: .teal, + title: "X-HWID", placeholder: "device id", + text: $settings.hwid) + } header: { + Text("Идентификация") + } footer: { Text("Эти заголовки отправляются на sub URL. Без правильных значений сервер отклоняет запрос.") - .font(.footnote) - .foregroundColor(.secondary) } + + Section("Оформление") { + accentPicker + Picker("Тема", selection: $settings.appearance) { + ForEach(AppAppearance.allCases) { mode in + Text(mode.label).tag(mode) + } + } + .pickerStyle(.segmented) + } + Section { NavigationLink { AboutView() } label: { - Label("О приложении", systemImage: "info.circle") + Label("О happwn", systemImage: "info.circle") + } + Link(destination: URL(string: "https://github.com/useruserdev/happwn")!) { + Label("Исходники на GitHub", systemImage: "chevron.left.forwardslash.chevron.right") } } } .navigationTitle("Настройки") } + + private func labeledField(icon: String, tint: Color, title: String, + placeholder: String, text: Binding) -> some View { + HStack(spacing: 12) { + IconBadge(systemName: icon, color: tint) + VStack(alignment: .leading, spacing: 1) { + Text(title).font(.caption).foregroundStyle(.secondary) + TextField(placeholder, text: text) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + } + } + + private var accentPicker: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Акцентный цвет").font(.callout) + LazyVGrid(columns: columns, spacing: 14) { + ForEach(AppAccent.allCases) { accent in + Circle() + .fill(accent.color) + .frame(width: 32, height: 32) + .overlay { + if settings.accent == accent { + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.white) + } + } + .overlay { + Circle() + .strokeBorder(Color.primary.opacity(settings.accent == accent ? 0.25 : 0), lineWidth: 2) + .padding(-3) + } + .contentShape(Circle()) + .onTapGesture { + settings.accent = accent + Haptics.tap() + } + .accessibilityLabel(accent.rawValue) + } + } + } + .padding(.vertical, 4) + } } diff --git a/ios/happwn/UI/Theme.swift b/ios/happwn/UI/Theme.swift new file mode 100644 index 0000000..59c9821 --- /dev/null +++ b/ios/happwn/UI/Theme.swift @@ -0,0 +1,188 @@ +import SwiftUI +import UIKit + +// MARK: - Accent + +/// Selectable global tint, persisted by id. +enum AppAccent: String, CaseIterable, Identifiable { + case indigo, blue, teal, green, orange, pink, purple, red + + var id: String { rawValue } + + var color: Color { + switch self { + case .indigo: return .indigo + case .blue: return .blue + case .teal: return .teal + case .green: return .green + case .orange: return .orange + case .pink: return .pink + case .purple: return .purple + case .red: return .red + } + } +} + +// MARK: - Appearance + +/// Light / dark / follow-system preference. +enum AppAppearance: String, CaseIterable, Identifiable { + case system, light, dark + + var id: String { rawValue } + + var label: String { + switch self { + case .system: return "Система" + case .light: return "Светлая" + case .dark: return "Тёмная" + } + } + + var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } +} + +// MARK: - Protocol styling + +/// Per-scheme colour + short badge for config rows. +enum ProtocolStyle { + static func color(for scheme: String) -> Color { + switch scheme.lowercased() { + case "vless": return .green + case "vmess": return .orange + case "trojan": return .pink + case "ss", "ssr": return .cyan + case "hysteria", "hysteria2", "hy2": return .purple + case "tuic": return .blue + case "wireguard": return .teal + default: return .gray + } + } + + /// 1–2 char badge, e.g. "VL", "VM", "TR". + static func badge(for scheme: String) -> String { + switch scheme.lowercased() { + case "vless": return "VL" + case "vmess": return "VM" + case "trojan": return "TR" + case "ss": return "SS" + case "ssr": return "SR" + case "hysteria", "hysteria2", "hy2": return "HY" + case "tuic": return "TU" + case "wireguard": return "WG" + default: return scheme.prefix(2).uppercased() + } + } +} + +// MARK: - Layout tokens + +enum Layout { + static let cardRadius: CGFloat = 16 + static let rowSpacing: CGFloat = 10 + static let screenPadding: CGFloat = 16 +} + +// MARK: - Reusable components + +/// Section label above a grouped card (uppercase, secondary). +struct SectionLabel: View { + let title: String + var trailing: AnyView? = nil + + init(_ title: String) { + self.title = title + self.trailing = nil + } + + init(_ title: String, @ViewBuilder trailing: () -> T) { + self.title = title + self.trailing = AnyView(trailing()) + } + + var body: some View { + HStack { + Text(title.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .tracking(0.5) + Spacer() + trailing + } + .padding(.horizontal, 6) + } +} + +/// Inset-grouped card container drawn on the grouped background. +struct GroupedCard: View { + @ViewBuilder var content: Content + + var body: some View { + VStack(spacing: 0) { + content + } + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: Layout.cardRadius, style: .continuous)) + } +} + +/// Coloured rounded glyph used as a leading icon in cells. +struct IconBadge: View { + let systemName: String + let color: Color + var text: String? = nil + + var body: some View { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(color) + .frame(width: 29, height: 29) + .overlay { + if let text { + Text(text) + .font(.system(size: 12, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + } else { + Image(systemName: systemName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + } + } + } +} + +/// Prominent accent action button with an optional loading state. +struct PrimaryButton: View { + let title: String + var isLoading: Bool = false + var action: () -> Void + + var body: some View { + Button(action: action) { + ZStack { + Text(title).opacity(isLoading ? 0 : 1) + if isLoading { ProgressView().tint(.white) } + } + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 15) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} + +// MARK: - Haptics + +enum Haptics { + static func tap() { + let gen = UIImpactFeedbackGenerator(style: .light) + gen.impactOccurred() + } +} diff --git a/ios/happwn/happwnApp.swift b/ios/happwn/happwnApp.swift index a6552e1..3efb35a 100644 --- a/ios/happwn/happwnApp.swift +++ b/ios/happwn/happwnApp.swift @@ -6,8 +6,10 @@ struct HappwnApp: App { var body: some Scene { WindowGroup { - ContentView() + RootView() .environmentObject(settings) + .tint(settings.accent.color) + .preferredColorScheme(settings.appearance.colorScheme) } } } diff --git a/ios/project.yml b/ios/project.yml index f8beff7..e5b983c 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -8,8 +8,8 @@ settings: GENERATE_INFOPLIST_FILE: YES INFOPLIST_KEY_UILaunchScreen_Generation: YES INFOPLIST_KEY_CFBundleDisplayName: happwn - MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "1" + MARKETING_VERSION: "1.0.1" + CURRENT_PROJECT_VERSION: "2" SWIFT_VERSION: "5.0" TARGETED_DEVICE_FAMILY: "1,2" targets: