diff --git a/Blink.xcodeproj/project.pbxproj b/Blink.xcodeproj/project.pbxproj index 392047d51..269a3879e 100644 --- a/Blink.xcodeproj/project.pbxproj +++ b/Blink.xcodeproj/project.pbxproj @@ -311,6 +311,7 @@ D2771511287F0EA200D31F4E /* libbuild_cli.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D2771510287F0EA200D31F4E /* libbuild_cli.a */; }; D27AD9BC222FDD3D00379872 /* xcall.m in Sources */ = {isa = PBXBuildFile; fileRef = D27AD9BB222FDD3D00379872 /* xcall.m */; }; D27BBA1C20529FFF00AEA303 /* TermStream.m in Sources */ = {isa = PBXBuildFile; fileRef = D27BBA1B20529FFF00AEA303 /* TermStream.m */; }; + D27C4DA42987F124008427F2 /* BuildHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27C4DA32987F124008427F2 /* BuildHelp.swift */; }; D27D0118261202A400128C23 /* KeyUIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27D0117261202A400128C23 /* KeyUIError.swift */; }; D27D01272615F1BD00128C23 /* developer_setup.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = D27D01232615F1BD00128C23 /* developer_setup.xcconfig */; }; D2887A5622DC676F00701BD5 /* SpaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2887A5522DC676F00701BD5 /* SpaceController.swift */; }; @@ -331,6 +332,7 @@ D2A0C2172600D16300F0DF97 /* Protobuf_C_.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D2334ECB25C1C04700385378 /* Protobuf_C_.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D2A5221E230D279B0010AC04 /* SmarterTermInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A5221D230D279B0010AC04 /* SmarterTermInput.swift */; }; D2A52227231304FF0010AC04 /* UIGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A52226231304FE0010AC04 /* UIGestureRecognizer.swift */; }; + D2A54CB129801062009D79FE /* BuildAccountModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A54CB029801062009D79FE /* BuildAccountModel.swift */; }; D2A6398928CFA0B90066FD18 /* SwiftCBOR in Frameworks */ = {isa = PBXBuildFile; productRef = D2A6398828CFA0B90066FD18 /* SwiftCBOR */; }; D2A80979270713D200CD0FAF /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A80978270713D200CD0FAF /* FeatureFlags.swift */; }; D2A9B2F7272E6F26009FCBDE /* BlinkCode.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BDBFA3052728914F00C77798 /* BlinkCode.framework */; }; @@ -980,6 +982,7 @@ D27AD9BD222FDD5700379872 /* xcall.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = xcall.h; sourceTree = ""; }; D27BBA1A20529FFF00AEA303 /* TermStream.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TermStream.h; sourceTree = ""; }; D27BBA1B20529FFF00AEA303 /* TermStream.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TermStream.m; sourceTree = ""; }; + D27C4DA32987F124008427F2 /* BuildHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildHelp.swift; sourceTree = ""; }; D27D0117261202A400128C23 /* KeyUIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyUIError.swift; sourceTree = ""; }; D27D01232615F1BD00128C23 /* developer_setup.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = developer_setup.xcconfig; sourceTree = ""; }; D2887A5522DC676F00701BD5 /* SpaceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceController.swift; sourceTree = ""; }; @@ -1000,6 +1003,7 @@ D2A0C63E20AAD98D001CF38F /* ios_error.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ios_error.h; sourceTree = ""; }; D2A5221D230D279B0010AC04 /* SmarterTermInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmarterTermInput.swift; sourceTree = ""; }; D2A52226231304FE0010AC04 /* UIGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIGestureRecognizer.swift; sourceTree = ""; }; + D2A54CB029801062009D79FE /* BuildAccountModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildAccountModel.swift; sourceTree = ""; }; D2A80978270713D200CD0FAF /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; D2AB611D23AB5ACD00BE6585 /* UIApplication+Version.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIApplication+Version.m"; sourceTree = ""; }; D2AB611F23AB5AE000BE6585 /* UIApplication+Version.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIApplication+Version.h"; sourceTree = ""; }; @@ -2011,6 +2015,8 @@ isa = PBXGroup; children = ( D2B788872949E8CA00F19E4F /* BuildRegion.swift */, + D2A54CB029801062009D79FE /* BuildAccountModel.swift */, + D27C4DA32987F124008427F2 /* BuildHelp.swift */, ); path = Build; sourceTree = ""; @@ -2972,6 +2978,7 @@ BD9EA212271F824900874007 /* Publisher.swift in Sources */, D241CBDF23040734003D64A5 /* KBKeyAccessibilityElement.swift in Sources */, D2DE0DDE260331F300A69B6F /* NewKeyView.swift in Sources */, + D27C4DA42987F124008427F2 /* BuildHelp.swift in Sources */, D2AD8E7A27A2BAFA00DED28D /* ShakeDetector.swift in Sources */, D23890BD2900175100B5CEA6 /* FeatureColorPalette.swift in Sources */, D2D75EE021AFDA10007336B6 /* LayoutManager.m in Sources */, @@ -3079,6 +3086,7 @@ BD74A7A7290061DE00ED01CF /* WhatsNewInfo.swift in Sources */, D264D2B228F84592002B1B14 /* GridView.swift in Sources */, D2EFE1F520B7FAFC0087888B /* link_files.m in Sources */, + D2A54CB129801062009D79FE /* BuildAccountModel.swift in Sources */, D2C24418238E44AB0082C69C /* KBConfigView.swift in Sources */, D2BF5F7F265BA0A80070F839 /* UserDefaults.swift in Sources */, D2F330CA20A6CB840074ADD7 /* help.m in Sources */, @@ -3661,7 +3669,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 665; + CURRENT_PROJECT_VERSION = 681; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", @@ -3706,7 +3714,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 665; + CURRENT_PROJECT_VERSION = 681; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = BlinkFileProvider/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -3743,7 +3751,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 665; + CURRENT_PROJECT_VERSION = 681; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", @@ -3789,7 +3797,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 665; + CURRENT_PROJECT_VERSION = 681; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BlinkFileProviderUI/Info.plist; @@ -4398,7 +4406,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 665; + CURRENT_PROJECT_VERSION = 681; DEAD_CODE_STRIPPING = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; @@ -4443,7 +4451,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 665; + CURRENT_PROJECT_VERSION = 681; DEAD_CODE_STRIPPING = NO; DEFINES_MODULE = YES; ENABLE_BITCODE = NO; diff --git a/Blink/BuildApi.swift b/Blink/BuildApi.swift index adda2aebe..d183e78ce 100644 --- a/Blink/BuildApi.swift +++ b/Blink/BuildApi.swift @@ -33,6 +33,34 @@ import Foundation import RevenueCat + +struct BuildAccountInfo: Decodable { + let build_id: String + let region: String + let email: String +} + +struct BuildUsageBalance: Decodable { + let build_id: String + let balance_id: String + let status: String + let credits_available: UInt64 + let credits_consumed: UInt64 + let period_start: UInt64 + let period_end: UInt64 + let initial_outstanding_debit: UInt64 + let last_charges_timestamp: UInt64? + let previous_balance_id: String? + + var periodStartDate: Date { + Date(timeIntervalSince1970: TimeInterval(period_start)) + } + + var periodEndDate: Date { + Date(timeIntervalSince1970: TimeInterval(period_end)) + } +} + enum BuildAPIError: Error, LocalizedError { case invalidResponse case unexpectedResponseStatus(Int) @@ -72,22 +100,38 @@ enum BuildAPI { } - public static func accountInfo() async { - - let (code, data) = try await requestService(.init(getJson: _path("/account"))) - let s = String(data: data, encoding: .utf8)!; + public static func accountInfo() async throws -> BuildAccountInfo { + let (code, data) = await requestService(.init(getJson: _path("/account"))) + if code == 200 { + return try JSONDecoder().decode(BuildAccountInfo.self, from: data) + } + print("Unexpected response: \(code) \(String(data: data, encoding: .utf8) as Any)") + throw BuildAPIError.unexpectedResponseStatus(Int(code)) + } + + public static func accountCurrentUsageBalance() async throws -> BuildUsageBalance { + let (code, data) = await requestService(.init(getJson: _path("/account/current_usage_balance"))) if code == 200 { - print(s) + return try JSONDecoder().decode(BuildUsageBalance.self, from: data) } + throw BuildAPIError.unexpectedResponseStatus(Int(code)) + } + + public static func requestAccountDelete() async throws { + let (code, _) = await requestService(try .init(postJson: _path("/account/request_account_delete"))) + if code == 200 { + return + } + throw BuildAPIError.unexpectedResponseStatus(Int(code)) } private static func _baseURL() -> String { - let options = PublishingOptions.current; - if options.intersection([PublishingOptions.testFlight, PublishingOptions.developer]).isEmpty { - return "https://api.blink.build" - } else { - return "https://raw.api.blink.build" + if FeatureFlags.blinkBuildStaging { + if FileManager.default.fileExists(atPath: BlinkPaths.blinkBuildStagingMarkURL()!.path) { + return "https://raw.api.blink.build" + } } + return "https://api.blink.build" } private static func _path(_ path: String) -> URL { @@ -199,11 +243,20 @@ enum BuildAPI { extension URLRequest { - init(postJson url: URL, params: [String: Any]) throws { + init(postJson url: URL, params: [String: Any] = [:]) throws { self.init(url: url) self.httpMethod = "POST" + self.addValue("application/json", forHTTPHeaderField: "Content-Type") self.httpBody = try JSONSerialization.data(withJSONObject: params) + } + + init(deleteJson url: URL, params: [String: Any]? = nil) throws { + self.init(url: url) + self.httpMethod = "DELETE" self.addValue("application/json", forHTTPHeaderField: "Content-Type") + if let params = params { + self.httpBody = try JSONSerialization.data(withJSONObject: params) + } } init(getJson url: URL, params: [String: Any] = [:]) { @@ -213,4 +266,6 @@ extension URLRequest { self.httpMethod = "GET" self.addValue("application/json", forHTTPHeaderField: "Content-Type") } + + } diff --git a/Blink/FeatureFlags.swift b/Blink/FeatureFlags.swift index 6cd52884e..283fb8f7a 100644 --- a/Blink/FeatureFlags.swift +++ b/Blink/FeatureFlags.swift @@ -36,6 +36,7 @@ import Foundation extension FeatureFlags { @objc static let noSubscriptionNag = _enabled(for: .developer, .testFlight) @objc static let blinkBuild = _enabled(for: .developer, .testFlight) + @objc static let blinkBuildStaging = _enabled(for: .developer, .testFlight) @objc static let checkReceipt = _enabled(for: .legacy) @objc static let earlyAccessFeatures = _enabled(for: .developer, .testFlight) } diff --git a/Blink/Subscriptions/PurchasesUserModel.swift b/Blink/Subscriptions/PurchasesUserModel.swift index febd90ff1..655461d46 100644 --- a/Blink/Subscriptions/PurchasesUserModel.swift +++ b/Blink/Subscriptions/PurchasesUserModel.swift @@ -46,9 +46,6 @@ class PurchasesUserModel: ObservableObject { // @Published var flow: Int = 0 - // MARK: Signup - @Published var signupInProgress: Bool = false - // MARK: Migration states @Published var receiptIsVerified: Bool = false @@ -59,18 +56,6 @@ class PurchasesUserModel: ObservableObject { @Published var alertErrorMessage: String = "" @Published var migrationStatus: MigrationStatus = .validating - // MARK: Blink Build states - - @Published var email: String = "" { - didSet { - emailIsValid = !email.isEmpty && _emailPredicate.evaluate(with: email) - } - } - - @Published var emailIsValid: Bool = false - @Published var buildRegion: BuildRegion = BuildRegion.usEast1 - @Published var hasBuildToken: Bool = false - // MARK: Paywall @Published var paywallPageIndex: Int = 0 @@ -82,46 +67,13 @@ class PurchasesUserModel: ObservableObject { static let shared = PurchasesUserModel() func refresh() { - _checkBuildToken(animated: false) + BuildAccountModel.shared.checkBuildToken(animated: false) if self.plusProduct == nil || self.classicProduct == nil || self.buildBasicProduct == nil { self.fetchProducts() } } - private func _checkBuildToken(animated: Bool) { - let value = FileManager.default.fileExists(atPath: BlinkPaths.blinkBuildTokenURL().path) - guard self.hasBuildToken != value else { - return - } - if animated { - withAnimation { - self.hasBuildToken = value - } - } else { - self.hasBuildToken = value - } - } - func signup() async { - guard emailIsValid else { - self.alertErrorMessage = self.email.isEmpty ? "Email is Required" : "Valid email is Required" - return - } - - self.signupInProgress = true - - defer { - self.signupInProgress = false - } - - do { - try await BuildAPI.signup(email: self.email, region: self.buildRegion) - self._checkBuildToken(animated: true) - } catch { - self.alertErrorMessage = error.localizedDescription - } - } - func purchaseBuildBasic() async { guard let product = buildBasicProduct else { self.alertErrorMessage = "Product should be loaded" @@ -142,9 +94,8 @@ class PurchasesUserModel: ObservableObject { if canceled { return } - // we have subscription. Lets try to signin first - try await BuildAPI.trySignin() - self._checkBuildToken(animated: true) + + try await BuildAccountModel.shared.trySignIn() } catch { self.alertErrorMessage = error.localizedDescription } @@ -185,13 +136,12 @@ class PurchasesUserModel: ObservableObject { if EntitlementsManager.shared.build.active { Task { do { - try await BuildAPI.signin() + try await BuildAccountModel.shared.singin(); } catch { self.alertErrorMessage = error.localizedDescription } } } - self._checkBuildToken(animated: false) }) } diff --git a/BlinkConfig/BlinkPaths.h b/BlinkConfig/BlinkPaths.h index a1e534daf..17435537b 100644 --- a/BlinkConfig/BlinkPaths.h +++ b/BlinkConfig/BlinkPaths.h @@ -49,6 +49,7 @@ + (NSURL *) blinkURL; + (NSURL *) blinkBuildURL; + (NSURL *) blinkBuildTokenURL; ++ (NSURL *)blinkBuildStagingMarkURL; + (NSURL *) sshURL; + (NSURL *) blinkSSHConfigFileURL; + (NSURL *) blinkGlobalSSHConfigFileURL; diff --git a/BlinkConfig/BlinkPaths.m b/BlinkConfig/BlinkPaths.m index 9d14b05b6..f7155b4a6 100644 --- a/BlinkConfig/BlinkPaths.m +++ b/BlinkConfig/BlinkPaths.m @@ -161,6 +161,13 @@ + (NSURL *)blinkBuildTokenURL return [NSURL fileURLWithPath:[url stringByAppendingPathComponent:@".build.token"]]; } ++ (NSURL *)blinkBuildStagingMarkURL +{ + NSString *url = [self blinkBuild]; + return [NSURL fileURLWithPath:[url stringByAppendingPathComponent:@".staging"]]; +} + + + (NSURL *)sshURL { diff --git a/Settings/ViewControllers/Build/BuildHelp.swift b/Settings/ViewControllers/Build/BuildHelp.swift new file mode 100644 index 000000000..72231df5d --- /dev/null +++ b/Settings/ViewControllers/Build/BuildHelp.swift @@ -0,0 +1,400 @@ +////////////////////////////////////////////////////////////////////////////////// +// +// B L I N K +// +// Copyright (C) 2016-2019 Blink Mobile Shell Project +// +// This file is part of Blink. +// +// Blink is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Blink is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Blink. If not, see . +// +// In addition, Blink is also subject to certain additional terms under +// GNU GPL version 3 section 7. +// +// You should have received a copy of these additional terms immediately +// following the terms and conditions of the GNU General Public License +// which accompanied the Blink Source Code. If not, see +// . +// +//////////////////////////////////////////////////////////////////////////////// + +import SwiftUI + +struct Cmd: Hashable, Equatable { + let text: String + let args: [(String, String)] + + func hash(into hasher: inout Hasher) { + text.hash(into: &hasher) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.text == rhs.text + } + +} + +extension Cmd: Identifiable { + typealias ID = String + var id: ID { text.description.uppercased() } +} + + +var cmds = [ + Cmd(text: "up", args: [ + ("", "Start named container"), + ("node", "Start node container"), + ("postgres", "Start postgres container"), + ("asnible", "Start ansible tools"), + ("itzg/minecraft-server", "Start minecraft server"), + ]), + Cmd(text: "machine", args: [ + ("", "Manage your **build** machine"), + ("start", "Start machine"), + ("stop", "Stop machine"), + ("status", "Show machine machine"), + ("add-device", "Add **this** device to running machine") + ]), + Cmd(text: "mosh", args: [ + ("[]", "Mosh into container. Default container is htools") + ]), + Cmd(text: "down", args: [("", "Stop container")]), + + Cmd(text: "help", args: [("", "Show full cli help")]) +] + +var short_cmds = [ + Cmd(text: "up", args: [ + ("", "Start named container"), + ("node", "Start node container"), + ("postgres", "Start postgres container"), + ("asnible", "Start ansible tools"), + ]), + Cmd(text: "machine", args: [ + ("", "Manage your **build** machine"), + ("start", "Start machine"), + ("stop", "Stop machine"), + ("status", "Show machine machine"), + ("add-device", "Add **this** device to running machine") + ]), + Cmd(text: "mosh", args: [ + ("[]", "Mosh into container. Default container is htools") + ]), + Cmd(text: "down", args: [("", "Stop container")]), + + Cmd(text: "help", args: [("", "Show full cli help")]) + +] + + +extension VerticalAlignment { + enum BuildAligment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context[VerticalAlignment.center] + } + } + + public static let buildAlignment = VerticalAlignment(BuildAligment.self) +} + +public struct VTabView: View where Content: View, SelectionValue: Hashable { + + private var selection: Binding? + + private var indexPosition: IndexPosition + + private var content: () -> Content + + /// Creates an instance that selects from content associated with + /// `Selection` values. + public init(selection: Binding?, indexPosition: IndexPosition = .leading, @ViewBuilder content: @escaping () -> Content) { + self.selection = selection + self.indexPosition = indexPosition + self.content = content + } + + private var flippingAngle: Angle { + switch indexPosition { + case .leading: + return .degrees(0) + case .trailing: + return .degrees(180) + } + } + + public var body: some View { + GeometryReader { proxy in + TabView(selection: selection) { + content() + .frame(width: proxy.size.width, height: proxy.size.height) + .rotationEffect(.degrees(-90)) + .rotation3DEffect(flippingAngle, axis: (x: 1, y: 0, z: 0)) + } + .tabViewStyle(PageTabViewStyle()) + .indexViewStyle(.page(backgroundDisplayMode: .always)) + .frame(width: proxy.size.height, height: proxy.size.width) + .rotation3DEffect(flippingAngle, axis: (x: 1, y: 0, z: 0)) + .rotationEffect(.degrees(90), anchor: .topLeading) + .offset(x: proxy.size.width) + } + } + + public enum IndexPosition { + case leading + case trailing + } +} + +@available(iOS 14.0, *) +extension VTabView where SelectionValue == Int { + + public init(indexPosition: IndexPosition = .leading, @ViewBuilder content: @escaping () -> Content) { + self.selection = nil + self.indexPosition = indexPosition + self.content = content + } +} + +struct FlipModifier: ViewModifier { + let amount: Angle + + func body(content: Content) -> some View { + content + .rotation3DEffect( + amount, + axis: (x: 1.0, y: 0.0, z: 0.0), + perspective: 0.7 + ) + } + + static var zero: Self { + FlipModifier(amount: .zero) + } +} + +extension AnyTransition { + public static func flip(duration: Double = 0.3) -> Self { + let minDuration = 0.0002; + let half = duration * 0.5 - minDuration; + let fastOpacity = Self.opacity.animation(.linear(duration:minDuration).delay(half)) + + return Self.asymmetric( + insertion: .modifier( + active: FlipModifier(amount: .degrees(-180)), + identity: .zero + ).combined(with: fastOpacity), + removal: .modifier( + active: FlipModifier(amount: .degrees(180)), + identity: .zero + ).combined(with: fastOpacity) + ) + .animation(.easeInOut(duration: duration)) + } +} + +struct CmdView: View { + init(baseFont: Font, topOffset: CGFloat, cmd: Cmd, visible: Bool = false, idx: Int) { + self.baseFont = baseFont + self.cmd = cmd + self.visible = visible + self.topOffset = topOffset + if idx < cmd.args.count { + self.idx = idx + } else { + self.idx = 0 + } + } + + let baseFont: Font + let topOffset: CGFloat + let cmd: Cmd + var visible: Bool = false; + let idx: Int + + var body: some View { + VStack { + Spacer().frame(height: topOffset) + HStack(alignment: .firstTextBaseline) { + // just to take same space + Text("build").font(baseFont.monospaced()).hidden() + Text(cmd.text).font(self.idx == 0 ? baseFont.monospaced().bold(): baseFont.monospaced()).fixedSize(horizontal: true, vertical: true) + .transition(.opacity.animation(.linear(duration: 0.3))) + .id("cmd \(self.idx == 0)") + + Text(self.cmd.args[self.idx].0).font( + self.idx == 0 ? baseFont.monospaced() : baseFont.monospaced().bold() + ).foregroundStyle( + self.idx == 0 ? .secondary : .primary + ) + .transition(.flip()) + .id("args \(self.idx)") + .opacity( visible ? 1.0 : 0.0) + .animation(.easeIn(duration: 0.2).delay(0.3), value: self.visible) + Spacer() + } + HStack { + Text("build").font(baseFont.monospaced()).hidden() + Text(.init(self.cmd.args[self.idx].1)).font(.subheadline) + Spacer() + } + .opacity(visible ? 1.0 : 0.0) + .animation(.easeIn(duration: 0.3).delay(0.8), value: self.visible) + Spacer() + } + } +} + +struct GaugeProgressStyle: ProgressViewStyle { + var strokeWidth = 4.0 + + func makeBody(configuration: Configuration) -> some View { + let fractionCompleted = configuration.fractionCompleted ?? 0 + let style = StrokeStyle(lineWidth: strokeWidth, lineCap: .round) + + return ZStack { + Circle() + .stroke(Color.secondary, style: style) + .opacity(0.3) + Circle() + .trim(from: 0, to: fractionCompleted) + .stroke(Color.secondary, style: style) + .rotationEffect(.degrees(-90)) + } + } +} + +struct CmdListView: View { + let cmds: [Cmd] + let topOffset: CGFloat + let baseFont: Font + @State var idx: Int = 0 + @State var paused = false + @State var timer: Timer? = nil + + @State var progressValue: Double = 0 + @State var page: Int = 0 { + didSet { + self.idx = 0 + } + } + + var body: some View { + ZStack(alignment: .leading) { + VStack() { + Spacer().frame(height: topOffset) + Text("build").font(baseFont.monospaced()) + Spacer() + } + VTabView(selection: $page, indexPosition: .trailing) { + ForEach(Array(cmds.enumerated()), id: \.element) { (index, cmd) in + CmdView(baseFont: baseFont, topOffset: topOffset, cmd: cmd, visible: page == index, idx: self.idx).tag(index) + .onAppear { + self.idx = 0 + self.progressValue = 0 + } + } + } + } + .onTapGesture { + showNext() + } + .overlay(content: { + VStack { + Spacer() + HStack { + ProgressView(value: min(progressValue, 1.0), total: 1.0) + .progressViewStyle(GaugeProgressStyle()) + .frame(width: 38, height: 38) + .overlay { + VStack { + Spacer() + if paused { + Image(systemName: "play.fill").opacity(0.6) + } else { + Image(systemName: "pause.fill").opacity(0.3) + } + Spacer() + } + } + .padding(.bottom) + .opacity(0.8) + .contentShape(Rectangle()) + .onTapGesture { + self.paused.toggle() + } + + Spacer() + } + } + }) + .onAppear { + self.timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { t in + if paused { + return + } + withAnimation { + self.progressValue += 0.05; + } + if self.progressValue >= 1.0 { + self.showNext() + } + + }) + } + } + + func showNext() { + let cmd = cmds[self.page] + self.progressValue = 0 + withAnimation { + if cmd.args.count > self.idx + 1 { + self.idx += 1 + } else if cmds.count > self.page + 1 { + self.idx = 0 + self.page += 1 + } else { + self.idx = 0 + self.page = 0 + } + } + + } +} + +struct SizedCmdListView: View { + + func baseFont(geom: GeometryProxy) -> Font { + let width = geom.size.width + if width > 400 { + return .largeTitle + } else if width > 300 { + return .title2 + } else { + return .title3 + } + } + + var body: some View { + GeometryReader { geom in + let topOffset = geom.size.height * 0.5 - 30 + let baseFont = self.baseFont(geom: geom) + let cmds = geom.size.width < 400 ? short_cmds : cmds + + CmdListView(cmds: cmds, topOffset: topOffset, baseFont: baseFont) + .frame(width: geom.size.width, height: geom.size.height) + } + } +} + + diff --git a/Settings/ViewControllers/Build/BuildRegion.swift b/Settings/ViewControllers/Build/BuildRegion.swift index cfe2e3648..0b4d277d7 100644 --- a/Settings/ViewControllers/Build/BuildRegion.swift +++ b/Settings/ViewControllers/Build/BuildRegion.swift @@ -44,6 +44,10 @@ enum BuildRegion: String { case test = "test_region" } +extension BuildRegion: Identifiable { + var id: String { self.rawValue } +} + extension BuildRegion { var location: String { switch self { @@ -62,7 +66,7 @@ extension BuildRegion { @ViewBuilder func fullTitleLabel() -> some View { - Label(self.full_title(), systemImage: systemImage()) + Label(self.fullTitle(), systemImage: systemImage()) } @ViewBuilder @@ -109,7 +113,7 @@ extension BuildRegion { } } - func full_title() -> String { + func fullTitle() -> String { switch self { case .usEast1: return "US East Region" case .usWest1: return "US West Region" diff --git a/Settings/ViewControllers/Subscriptions/BuildView.swift b/Settings/ViewControllers/Subscriptions/BuildView.swift index 6aadd663c..0e9dab96c 100644 --- a/Settings/ViewControllers/Subscriptions/BuildView.swift +++ b/Settings/ViewControllers/Subscriptions/BuildView.swift @@ -34,40 +34,152 @@ import SwiftUI import RevenueCat import Charts -//struct BuildRegionPickerView: View { -// @Binding var currentValue: BuildRegion -// @EnvironmentObject var nav: Nav -// -// var body: some View { -// List { -// Section() { -// ForEach(BuildRegion.all(), id: \.self) { value in -// HStack { -// value.fullTitleLabel() -// Spacer() -// Checkmark(checked: currentValue == value) -// } -// .contentShape(Rectangle()) -// .onTapGesture { -// currentValue = value -// nav.navController.popViewController(animated: true) -// } -// } -// } -// } -// .listStyle(InsetGroupedListStyle()) -// .navigationTitle("Build Region") -// .tint(Color("BuildColor")) -// } -//} +struct BuildRegionPickerView: View { + @Binding var currentValue: BuildRegion + @EnvironmentObject var nav: Nav + + var body: some View { + List { + Section(header: Text("Choose nearest region")) { + ForEach(BuildRegion.all(), id: \.self) { value in + HStack { + value.fullTitleLabel() + Spacer() + Checkmark(checked: currentValue == value) + } + .contentShape(Rectangle()) + .onTapGesture { + currentValue = value + nav.navController.popViewController(animated: true) + } + } + } + } + .listStyle(InsetGroupedListStyle()) + .navigationTitle("Build Region") + .tint(Color("BuildColor")) + } + } + +struct BasicMachineSection: View { + let nspace : Namespace.ID; + var footer: String = "" + + var body: some View { + Section( + header:VStack(alignment: .leading) { + HStack { + Spacer() + Image("build-logo").matchedGeometryEffect(id: "logo", in: self.nspace) + Spacer() + }.padding(.bottom).offset(y: -32) + HStack { + Text("Basic Machine") + } + }, + footer: Text(footer) + ) { + Label { + Text("4 GiB of RAM") + } icon: { + Image(systemName: "memorychip") + .foregroundColor(.green) + + } + Label { + Text("2 vCPUs") + } icon: { + Image(systemName: "cpu") + .foregroundColor(.green) + } + Label { + Text("4,000 GiB Transfer") + } icon: { + Image(systemName: "network") + .foregroundColor(.green) + } + Label { + Text("60 GiB Ephemeral SSD") + } icon: { + Image(systemName: "internaldrive") + .foregroundColor(.green) + } + Label { + Text("5 GiB Main Cloud Disk") + } icon: { + Image(systemName: "externaldrive.badge.icloud") + .foregroundColor(.green) + } + Label { + Text("50 Hours") + } icon: { + Image(systemName: "timer") + .foregroundColor(.green) + } + } + } +} + +struct BasicMachinePlanView: View { + @ObservedObject private var _purchases: PurchasesUserModel = .shared + @ObservedObject private var _account: BuildAccountModel = .shared + + let nspace : Namespace.ID; + + var body: some View { + GeometryReader { proxy in + let compact = proxy.size.width < 400 + + List { + BasicMachineSection(nspace: self.nspace) + + Section(header: Text("Available Regions")) { + ForEach(BuildRegion.available()) { region in + if compact { + region.fullTitleLabel() + } else { + region.largeTitleLabel() + } + } + } + + Section(header: Text("Price")) { + Label("1\u{00a0}month\u{00a0}free, then \(_purchases.formattedBuildPriceWithPeriod() ?? "").", systemImage: "bag") + } + Section { + Button(action: { + _account.openTermsOfService() + }, label: { Label("Terms of Service", systemImage: "link").foregroundColor(.green) }) + } + } + } + .toolbar(content: { + Button(action: { + _account.showInfo = false + }, label: { Label("", systemImage: "xmark.circle").foregroundColor(.green) }) + .symbolRenderingMode(.hierarchical) + }) + .tint(.green) + .onDisappear { + _account.showInfo = false + } + } +} struct BuildView: View { - @ObservedObject private var _model: PurchasesUserModel = .shared + @ObservedObject private var _purchases: PurchasesUserModel = .shared + @ObservedObject private var _account: BuildAccountModel = .shared @ObservedObject private var _entitlements: EntitlementsManager = .shared @Namespace var nspace; var body: some View { -// BuildCreateAccountView(nspace: self.nspace) +// if _account.showInfo { +// BasicMachinePlanView(nspace: self.nspace) +// } else { +// BuildIntroView(nspace: self.nspace) +// } +// BuildAccountView(nspace: self.nspace) + // if _model.flow == 0 { // BuildIntroView(nspace: self.nspace) // } else if _model.flow == 1 { @@ -75,21 +187,22 @@ struct BuildView: View { // } else { // BuildAccountView(nspace: self.nspace) // } - if _model.hasBuildToken { + + + if _account.hasBuildToken { BuildAccountView(nspace: self.nspace) - } else if _entitlements.build.active && !_model.purchaseInProgress { + } else if _entitlements.build.active && !_purchases.purchaseInProgress { BuildCreateAccountView(nspace: self.nspace) } else { - BuildIntroView(nspace: self.nspace) + if _account.showInfo { + BasicMachinePlanView(nspace: self.nspace) + } else { + BuildIntroView(nspace: self.nspace) + } } } } - - - - - private struct LayoutProps { let h1: CGFloat let h2: CGFloat @@ -105,7 +218,7 @@ private struct LayoutProps { var gridOffset: CGSize = .zero if size.width > size.height { - gridOffset = CGSize(width: 70, height: 24) + gridOffset = CGSize(width: 100, height: 46) gridScale = 0.8 } @@ -166,7 +279,8 @@ private struct LayoutProps { struct BuildIntroView: View { @State var scale = 1.3 - @ObservedObject private var _model: PurchasesUserModel = .shared + @ObservedObject private var _purchases: PurchasesUserModel = .shared + @ObservedObject private var _account: BuildAccountModel = .shared @ObservedObject private var _entitlements: EntitlementsManager = .shared @EnvironmentObject private var _nav: Nav let nspace : Namespace.ID @@ -200,24 +314,32 @@ struct BuildIntroView: View { if _entitlements.earlyAccessFeatures.active { - Text("Get 2 Free months to Build") + Text("Get Free month to Build") .fixedSize(horizontal: false, vertical: true) .font(.system(size: props.h1, weight: .bold)) .padding([.top]) - Text("Run work environments from all your devices.\n2\u{00a0}months\u{00a0}free, then $7.99/month.") - .fixedSize(horizontal: false, vertical: true) + Text("Run work environments from all your devices.") .font(.system(size: props.h2)) - .padding([.bottom]) + Text("[Basic Machine](#info) 1\u{00a0}month\u{00a0}free, then \(_purchases.formattedBuildPriceWithPeriod() ?? "").") + .fixedSize(horizontal: false, vertical: true) + .font(.system(size: props.h2)) + .padding([.bottom]) + .environment(\.openURL, OpenURLAction(handler: { url in + withAnimation { + _account.showInfo = true + } + return .handled + })) - if _model.restoreInProgress || _model.purchaseInProgress || _model.hasBuildToken { + if _purchases.restoreInProgress || _purchases.purchaseInProgress || _account.hasBuildToken { ProgressView() .frame(maxWidth: .infinity, minHeight: props.button, maxHeight: props.button) .padding([.top, .bottom]) } else { Button { Task { - await _model.purchaseBuildBasic() + await _purchases.purchaseBuildBasic() } } label: { Text("Try it Free") @@ -228,19 +350,26 @@ struct BuildIntroView: View { .frame(minHeight: props.button, maxHeight: props.button) .padding([.top, .bottom]) } + HStack { + Spacer() + Button("Terms of Use", action: { + _account.openTermsOfService() + }).padding(.trailing) + Button("Restore Purchases", action: { + _purchases.restorePurchases() + }) + Spacer() + }.padding(.bottom).disabled(_purchases.restoreInProgress) } else { Text("This is Early Access Blink+ Service") .fixedSize(horizontal: false, vertical: true) .font(.system(size: props.h1, weight: .bold)) .padding([.top]) - Text("Run work environments from all your devices.\n2\u{00a0}months\u{00a0}free, then $7.99/month.") + Text("Run work environments from all your devices.\n1\u{00a0}month\u{00a0}free, then \(_purchases.formattedBuildPriceWithPeriod() ?? "").") .fixedSize(horizontal: false, vertical: true) .font(.system(size: props.h2)) .padding([.bottom]) Button { -// withAnimation { -// self._model.flow = 1 -// } let vc = UIHostingController(rootView: PlansView()) _nav.navController.pushViewController(vc, animated: true) } label: { @@ -254,25 +383,14 @@ struct BuildIntroView: View { } } .frame(maxWidth: 574) - .alert(errorMessage: $_model.alertErrorMessage) + .alert(errorMessage: $_purchases.alertErrorMessage) .padding(props.padding) } .navigationTitle("") - .toolbar { - Button( - action: { - openURL(URL(string: "https://blink.build")!) - }, - label: { Label("", systemImage: "info.circle") } - ) - .symbolRenderingMode(.hierarchical) - } .padding(.bottom) .tint(Color("BuildColor")) } } - - } struct BuildCreateAccountView: View { @@ -282,106 +400,89 @@ struct BuildCreateAccountView: View { @State var showAllRegions = false @State var idiom = UIDevice.current.userInterfaceIdiom - @ObservedObject private var _model: PurchasesUserModel = .shared + @ObservedObject private var _account: BuildAccountModel = .shared + @ObservedObject private var _purchases: PurchasesUserModel = .shared let nspace : Namespace.ID; @FocusState private var focusedField: Field? var body: some View { List { + BasicMachineSection(nspace: self.nspace) + .onTapGesture { + self.focusedField = nil + } + Section( - header:VStack(alignment: .leading) { - HStack { - Spacer() - Image("build-logo").matchedGeometryEffect(id: "logo", in: self.nspace) - Spacer() - }.padding(.bottom).offset(y: -32) - HStack { - Text("Select region near you") - Spacer() - if FeatureFlags.blinkBuild { - Button("...") { - withAnimation { - self.showAllRegions.toggle() - } - } - } - } - }) + header: Text("Setup your Account"), + footer: Text("We will send you verification email.") + ) { - ForEach(showAllRegions ? BuildRegion.all() : BuildRegion.available(), id: \.self) { value in - HStack { - value.largeTitleLabel() - Spacer() - Checkmark(checked: _model.buildRegion == value) + Row( + content: { + _account.buildRegion.fullTitleLabel() + }, + details: { + BuildRegionPickerView(currentValue: $_account.buildRegion) } - .contentShape(Rectangle()) - .onTapGesture { - _model.buildRegion = value + ) + Label { + self.emailTextField() + .focused($focusedField, equals: .email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .submitLabel(.go) + .onSubmit { + Task { + await _account.signup() + } } + } icon: { + Image(systemName: "envelope.badge") + .symbolRenderingMode(_account.emailIsValid ? .monochrome : .multicolor) } } - Section( - header: Text("Contact"), - footer: - VStack { - if _model.signupInProgress { - HStack { - Spacer() - ProgressView() - Spacer() - } - } else { - Button { - Task { - // withAnimation { - // self._model.flow = 2 - // } - await _model.signup() - } - } label: { - Text("Sign up") - .font(.system(size: 20, weight: .bold)) - .frame(maxWidth: .infinity, maxHeight: .infinity) - }.foregroundColor(Color("BuildColor")) - .buttonStyle(.plain) - .frame(minHeight: 70, maxHeight: 70) - .padding([.top, .bottom]) - .opacity(self.idiom == .phone && self.focusedField == .email ? 0 : 1) - } + Section(footer: VStack { + if _account.signupInProgress { + HStack { + Spacer() + ProgressView().frame(minHeight: 70, maxHeight: 70).padding([.top, .bottom]) + Spacer() } - ) - { - Label { - TextField( - "Your Email for Notifications", text: $_model.email - ) - .focused($focusedField, equals: .email) - .textContentType(.emailAddress) - .keyboardType(.emailAddress) - .submitLabel(.go) - .onSubmit { - Task { - await _model.signup() - } + } else { + Button { + Task { + // withAnimation { + // self._model.flow = 2 + // } + await _account.signup() } - } icon: { - Image(systemName: "envelope.badge") - .symbolRenderingMode(_model.emailIsValid ? .monochrome : .multicolor) - } + } label: { + Text("Sign up") + .font(.system(size: 20, weight: .bold)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + }.foregroundColor(Color("BuildColor")) + .buttonStyle(.plain) + .frame(minHeight: 70, maxHeight: 70) + .padding([.top, .bottom]) + .opacity(self.idiom == .phone && self.focusedField == .email ? 0 : 1) } + }) { + EmptyView() +// Button( +// action: { _account.openTermsOfService() }, +// label: { Label("Terms of Service", systemImage: "link").foregroundColor(.green) } +// ) + } } - .disabled(_model.purchaseInProgress || _model.restoreInProgress || _model.signupInProgress) - .alert(errorMessage: $_model.alertErrorMessage) + .disabled(_purchases.purchaseInProgress || _purchases.restoreInProgress || _account.signupInProgress) + .alert(errorMessage: $_account.alertErrorMessage) .navigationTitle("") - .onTapGesture { - self.focusedField = nil - } .toolbar { - if self.idiom == .phone && focusedField == .email && !_model.signupInProgress { + if self.idiom == .phone && focusedField == .email && !_account.signupInProgress { Button("Signup") { Task { - await _model.signup() + await _account.signup() } } } @@ -389,91 +490,251 @@ struct BuildCreateAccountView: View { .tint(.green) } + + @ViewBuilder + func emailTextField() -> some View { + if #available(iOS 16.0, *) { + TextField( + "Your Email for Notifications", text: $_account.email + ) + .scrollDismissesKeyboard(.interactively) + } else { + TextField( + "Your Email for Notifications", text: $_account.email + ) + } + } +} + +struct BuildPeriodSection: View { + let balance: BuildUsageBalance + + var body: some View { + Section(header: Text("Period")) { + HStack { + Label("Start", systemImage: "calendar") + Spacer() + Text(balance.periodStartDate.formatted()) + } + HStack { + Label("End", systemImage: "calendar.badge.clock") + Spacer() + Text(balance.periodEndDate.formatted()) + } + HStack { + Label("Status", systemImage: "wallet.pass") + Spacer() + Text(balance.status) + } + } + } } -struct BuildAccountView: View { - let nspace : Namespace.ID; - @ObservedObject private var _model: PurchasesUserModel = .shared - @ObservedObject private var _entitlements: EntitlementsManager = .shared +struct BuildCreditsSection: View { + let balance: BuildUsageBalance - @ViewBuilder - func list() -> some View { - if #available(iOS 16.0, *) { - Chart { - BarMark( - x: .value("Mount", "Mon"), - y: .value("Value", 3) - ) - BarMark( - x: .value("Mount", "Tue"), - y: .value("Value", 4) - ) - BarMark( - x: .value("Mount", "Wed"), - y: .value("Value", 7) - ) - BarMark( - x: .value("Mount", "Thu"), - y: .value("Value", 2) - ) - - BarMark( - x: .value("Mount", "Fri"), - y: .value("Value", 7) - ) - BarMark( - x: .value("Mount", "Sat"), - y: .value("Value", 8) - ) - BarMark( - x: .value("Mount", "Sun"), - y: .value("Value", 9) - ) - RuleMark( - y: .value("Average", 5.7) - ) - .foregroundStyle(.yellow) - .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [3, 5])) - .annotation(position: .trailing, alignment: .leading) { - Text("avg") - .font(.caption2) - .foregroundStyle(.yellow) - } + var body: some View { + Section(header: Text("Credits")) { + HStack { + Label("Consumed", systemImage: "number.circle") + Spacer() + Text("\(balance.credits_consumed)") + } + HStack { + Label("Available", systemImage: "number.circle.fill") + Spacer() + Text("\(balance.credits_available)") } - .frame(height: 100) - } else { - EmptyView() } } +} + +struct BuildAccountView: View { + let nspace : Namespace.ID; + @State var showHelp: Bool = false + @ObservedObject private var _model: BuildAccountModel = .shared + @ObservedObject private var _entitlements: EntitlementsManager = .shared + @State var showDeleteAccountAlert = false + @EnvironmentObject var _nav: Nav; var body: some View { - List { - Section(header: VStack(alignment: .leading) { + if _model.email.isEmpty { + VStack { + Spacer() Image("build-logo") .matchedGeometryEffect(id: "logo", in: self.nspace) - .offset(x: -10, y: -32) - Text("Account") + Spacer() + if _model.accountInfoLoadingInProgress { + ProgressView() + } else { + Button { + Task { + await _model.fetchAccountInfo() + } + } label: { + Label("Retry", systemImage: "arrow.triangle.2.circlepath") + } + } + Spacer() + } + .tint(.green) + .alert(errorMessage: $_model.alertErrorMessage) + .navigationTitle("") + .toolbar(content: { + if !_model.accountInfoLoadingInProgress { + Button { + let vc = UIHostingController(rootView: BuildSupportView(email: _model.email)) + _nav.navController.pushViewController(vc, animated: true) + } label: { + Label("Support", systemImage: "lifepreserver") + } + } }) - { - Label { - Text(verbatim: "yury@build.sh") - } icon: { - Image(systemName: "envelope.badge") - .symbolRenderingMode(.monochrome) + .task { + if _model.email.isEmpty { + await _model.fetchAccountInfo() } - _model.buildRegion.largeTitleLabel() } - Section(header: Text("Usage")) { - list().accentColor(.green) - }.onAppear(perform: { - Task { - await BuildAPI.accountInfo() + } else { + GeometryReader { proxy in + let compact = proxy.size.width < 400 + + List { + Section(header: VStack(alignment: .leading) { + Image("build-logo") + .matchedGeometryEffect(id: "logo", in: self.nspace) + .offset(x: -10, y: -32) + Text("Account \(proxy.size.width)") + }) + { + Label { + Text(_model.email) + } icon: { + Image(systemName: "envelope.badge") + .symbolRenderingMode(.monochrome) + } + if compact { + _model.buildRegion.fullTitleLabel() + } else { + _model.buildRegion.largeTitleLabel() + } + } + if let balance = _model.usageBalance { + BuildCreditsSection(balance: balance) + BuildPeriodSection(balance: balance) + } + Section { + Row { + Label("Support", systemImage: "lifepreserver") + } details: { + BuildSupportView(email: _model.email) + } + } + Section { + if FeatureFlags.blinkBuildStaging { + Toggle(isOn: $_model.isStagingEnv, label: { + Label("Staging env", systemImage: "wrench.and.screwdriver") + }) + } + Button { + self.showDeleteAccountAlert = true + } label: { + Label("Delete Account", systemImage: "hand.raised") + } + .alert(isPresented: $showDeleteAccountAlert, content: { + Alert( + title: Text("Warning"), + message: Text("You account will be scheduled for deletion."), + primaryButton: .destructive(Text("Delete"), action: { + Task { + await _model.requestAccountDelete() + } + }), + secondaryButton: .cancel() + ) + }) + } } + } + .task { + await _model.fetchUsageBalance() + } + .refreshable { + await _model.fetchAccountInfo() + await _model.fetchUsageBalance() + } + .tint(.green) + .alert(errorMessage: $_model.alertErrorMessage) + + .navigationTitle("") + .toolbar(content: { + Button("Help", action: { + self.showHelp.toggle() + }) }) + + .overlay { + if showHelp { + SizedCmdListView() + .padding([.leading, .trailing, .bottom]) + .padding(.bottom) + .padding(.bottom) + .background( + Rectangle() + .foregroundColor(Color(UIColor.systemBackground)) + .ignoresSafeArea(.all) + ) + } else { + EmptyView() + } + } + } + } +} + + +struct BuildSupportView: View { + public let email: String + + func emailStr() -> String { + if email.isEmpty { + return "" + } + + return " (\(email))" + } + + var body: some View { + VStack { + ScrollView(.vertical) { + VStack(alignment: .leading) { + Text( + "Thanks for using Blink Build and helping us make this app even more epic! " + ).font(.title).fixedSize(horizontal: false, vertical: true) + .padding(.bottom).padding(.bottom) + Text( + "If you’re facing any usage roadblocks, check out our documentation or Community Resources like [GitHub Discussions](https://github.com/blinksh/blink/discussions) or [Discord](https://discord.gg/ZTtMfvK)." + ) + .padding(.bottom) + Text( + "If it's an account-related problem (e.g. machines, login, or accounting), send an email to support@blink.build from your registered account\(self.emailStr()). Our team will be on it ASAP to help resolve the issue." + ) + } + .frame(minWidth: 240, maxWidth: 600) + .padding([.leading, .trailing]) + + Spacer().frame(maxWidth: .infinity, minHeight: 620, maxHeight: .infinity) + .overlay { + VStack { + Spacer().frame(height: 40) + Image("iso-grid") + Spacer() + } + } +// Spacer().background(content: { Image("iso-grid") }) +// Image("iso-grid").fixedSize().scaledToFit() + }.ignoresSafeArea(edges: [.leading, .bottom, .trailing]).tint(.green) } - .tint(.green) - .alert(errorMessage: $_model.alertErrorMessage) - .navigationTitle("") } }