NightscoutConnectStepView.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. // LoopFollow
  2. // NightscoutConnectStepView.swift
  3. import SwiftUI
  4. /// The Nightscout connect phase, split into two internal pages:
  5. /// 1. **Address** — enter the site URL; we validate it. A public site (or a URL
  6. /// that already carries a token) is done here. A reachable site that needs a
  7. /// token advances to page 2. An unreachable/invalid address stays put with the
  8. /// error shown in the status pill so the user can correct it.
  9. /// 2. **Token** — paste a token or create a read-only one from the API secret.
  10. struct NightscoutConnectStepView: View {
  11. @ObservedObject var viewModel: NightscoutSettingsViewModel
  12. @ObservedObject var onboarding: OnboardingViewModel
  13. private enum Page { case address, token }
  14. private enum TokenMode: Hashable { case haveToken, createFromSecret }
  15. @State private var page: Page = .address
  16. @State private var mode: TokenMode = .haveToken
  17. @State private var apiSecret: String = ""
  18. var body: some View {
  19. VStack(spacing: 0) {
  20. ConnectionStatusPill(
  21. color: statusColor,
  22. message: viewModel.friendlyStatus,
  23. isLoading: viewModel.statusKind == .checking,
  24. systemImage: statusIcon
  25. )
  26. .padding(.horizontal)
  27. .padding(.top, 12)
  28. .padding(.bottom, 8)
  29. .animation(.easeInOut(duration: 0.25), value: viewModel.statusKind)
  30. switch page {
  31. case .address:
  32. addressForm
  33. case .token:
  34. tokenForm
  35. }
  36. footer
  37. }
  38. }
  39. // MARK: - Pages
  40. private var addressForm: some View {
  41. Form {
  42. titleSection(
  43. "Connect to Nightscout",
  44. "Enter your site address. We'll check it, and only ask for a token if your site needs one."
  45. )
  46. urlSection
  47. }
  48. }
  49. private var tokenForm: some View {
  50. Form {
  51. if viewModel.isConnected || viewModel.provisionedTokenPending {
  52. tokenDoneSection
  53. } else {
  54. titleSection(
  55. "Add a token",
  56. "This site needs a token. Paste one, or have LoopFollow create a read-only token from your API secret."
  57. )
  58. tokenModeSection
  59. switch mode {
  60. case .haveToken:
  61. tokenSection
  62. case .createFromSecret:
  63. secretSection
  64. }
  65. }
  66. }
  67. }
  68. /// Shown once a token is in place, so the page reads as "done — continue"
  69. /// rather than still asking the user to do something.
  70. private var tokenDoneSection: some View {
  71. Section {
  72. EmptyView()
  73. } header: {
  74. VStack(alignment: .leading, spacing: 10) {
  75. Text(viewModel.provisionedTokenPending ? "Token created" : "You're connected")
  76. .font(.title2.weight(.bold))
  77. .foregroundColor(.primary)
  78. Text(viewModel.provisionedTokenPending
  79. ? "Your read-only token is ready. Tap Continue to keep going — your site may take a few minutes to start accepting it."
  80. : "Your read-only token is set up. Tap Continue to keep going.")
  81. .font(.subheadline)
  82. .foregroundColor(.secondary)
  83. }
  84. .frame(maxWidth: .infinity, alignment: .leading)
  85. .textCase(nil)
  86. .padding(.bottom, 8)
  87. }
  88. .listRowInsets(EdgeInsets())
  89. .listRowBackground(Color.clear)
  90. }
  91. @ViewBuilder
  92. private var footer: some View {
  93. switch page {
  94. case .address:
  95. OnboardingNavFooter(
  96. continueEnabled: viewModel.isConnected || viewModel.addressNeedsToken,
  97. showBack: onboarding.canGoBack,
  98. onBack: { onboarding.goBack() },
  99. onContinue: addressContinue
  100. )
  101. case .token:
  102. OnboardingNavFooter(
  103. continueEnabled: viewModel.isConnected || viewModel.provisionedTokenPending,
  104. showBack: true,
  105. onBack: { withAnimation(.easeInOut(duration: 0.25)) { page = .address } },
  106. onContinue: { onboarding.advance() }
  107. )
  108. }
  109. }
  110. private func addressContinue() {
  111. if viewModel.isConnected {
  112. onboarding.advance()
  113. } else if viewModel.addressNeedsToken {
  114. withAnimation(.easeInOut(duration: 0.25)) { page = .token }
  115. }
  116. }
  117. // MARK: - Sections
  118. private func titleSection(_ title: String, _ subtitle: String) -> some View {
  119. Section {
  120. EmptyView()
  121. } header: {
  122. VStack(alignment: .leading, spacing: 6) {
  123. Text(title)
  124. .font(.title2.weight(.bold))
  125. .foregroundColor(.primary)
  126. Text(subtitle)
  127. .font(.subheadline)
  128. .foregroundColor(.secondary)
  129. }
  130. .frame(maxWidth: .infinity, alignment: .leading)
  131. .textCase(nil)
  132. .padding(.bottom, 8)
  133. }
  134. .listRowInsets(EdgeInsets())
  135. .listRowBackground(Color.clear)
  136. }
  137. private var urlSection: some View {
  138. Section {
  139. // `verbatim:` keeps SwiftUI from auto-linking the example URL in accent
  140. // blue (which reads as an "active" field).
  141. ZStack(alignment: .leading) {
  142. if viewModel.nightscoutURL.isEmpty {
  143. Text(verbatim: "https://your-site.example.com")
  144. .foregroundColor(.secondary)
  145. }
  146. TextField("", text: $viewModel.nightscoutURL)
  147. .textContentType(.URL)
  148. .keyboardType(.URL)
  149. .autocapitalization(.none)
  150. .disableAutocorrection(true)
  151. .foregroundColor(.primary)
  152. .onChange(of: viewModel.nightscoutURL) { newValue in
  153. viewModel.processURL(newValue)
  154. }
  155. }
  156. } header: {
  157. Text("Site URL")
  158. } footer: {
  159. Text("Enter or paste your full Nightscout address. If the link already includes a token, we'll fill both in for you.")
  160. }
  161. }
  162. private var tokenModeSection: some View {
  163. Section {
  164. Picker("Token", selection: $mode) {
  165. Text("I have a token").tag(TokenMode.haveToken)
  166. Text("Create one for me").tag(TokenMode.createFromSecret)
  167. }
  168. .pickerStyle(.segmented)
  169. } footer: {
  170. if mode == .createFromSecret {
  171. Text("Your API secret is used once to create a read-only access token and is never stored.")
  172. } else {
  173. Text("Type or paste a token, or a full Nightscout URL that includes a token.")
  174. }
  175. }
  176. }
  177. private var tokenSection: some View {
  178. Section(header: Text("Access Token")) {
  179. HStack {
  180. Text("Token")
  181. TogglableSecureInput(
  182. placeholder: "Enter Token",
  183. text: $viewModel.nightscoutToken,
  184. style: .singleLine,
  185. textContentType: .password
  186. )
  187. }
  188. if viewModel.tokenIsVerifiedSecret || viewModel.isProvisioningToken {
  189. Button {
  190. viewModel.createReadOnlyToken(fromSecret: viewModel.nightscoutToken)
  191. } label: {
  192. HStack {
  193. if viewModel.isProvisioningToken {
  194. ProgressView()
  195. Text("Creating read-only token…")
  196. } else {
  197. Image(systemName: "wand.and.stars")
  198. Text("That's your API secret — create a read-only token")
  199. }
  200. }
  201. }
  202. .disabled(viewModel.isProvisioningToken)
  203. }
  204. if let error = viewModel.tokenProvisionError {
  205. Text(error)
  206. .font(.footnote)
  207. .foregroundColor(.red)
  208. }
  209. }
  210. }
  211. private var secretSection: some View {
  212. Section(header: Text("API Secret")) {
  213. HStack {
  214. Text("Secret")
  215. TogglableSecureInput(
  216. placeholder: "Enter API Secret",
  217. text: $apiSecret,
  218. style: .singleLine,
  219. textContentType: .password
  220. )
  221. }
  222. Button {
  223. viewModel.createReadOnlyToken(fromSecret: apiSecret)
  224. } label: {
  225. HStack {
  226. Spacer()
  227. if viewModel.isProvisioningToken {
  228. ProgressView()
  229. } else {
  230. Text("Create Read-Only Token")
  231. .fontWeight(.semibold)
  232. }
  233. Spacer()
  234. }
  235. }
  236. .disabled(viewModel.isProvisioningToken
  237. || apiSecret.isEmpty
  238. || viewModel.nightscoutURL.isEmpty)
  239. if let error = viewModel.tokenProvisionError {
  240. Text(error)
  241. .font(.footnote)
  242. .foregroundColor(.red)
  243. }
  244. }
  245. }
  246. // MARK: - Status pill mapping
  247. private var statusColor: Color {
  248. switch viewModel.statusKind {
  249. case .idle: return .secondary
  250. case .checking: return .orange
  251. case .pending: return .blue
  252. case .needsToken, .connected: return .green
  253. case .error: return .red
  254. }
  255. }
  256. private var statusIcon: String? {
  257. switch viewModel.statusKind {
  258. case .idle: return "globe"
  259. case .checking: return nil
  260. case .pending: return "clock.badge.checkmark"
  261. case .needsToken: return "checkmark.circle"
  262. case .connected: return "checkmark.circle.fill"
  263. case .error: return "exclamationmark.triangle.fill"
  264. }
  265. }
  266. }