ScheduleEditor.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. //
  2. // ScheduleEditor.swift
  3. // LoopKitUI
  4. //
  5. // Created by Michael Pangburn on 4/24/20.
  6. // Copyright © 2020 LoopKit Authors. All rights reserved.
  7. //
  8. import SwiftUI
  9. import HealthKit
  10. import LoopKit
  11. enum SavingMechanism<Value> {
  12. case synchronous((_ value: Value) -> Void)
  13. case asynchronous((_ value: Value, _ completion: @escaping (Error?) -> Void) -> Void)
  14. func pullback<NewValue>(_ transform: @escaping (NewValue) -> Value) -> SavingMechanism<NewValue> {
  15. switch self {
  16. case .synchronous(let save):
  17. return .synchronous { newValue in save(transform(newValue)) }
  18. case .asynchronous(let save):
  19. return .asynchronous { newValue, completion in
  20. save(transform(newValue), completion)
  21. }
  22. }
  23. }
  24. }
  25. enum SaveConfirmation {
  26. case required(AlertContent)
  27. case notRequired
  28. }
  29. struct ScheduleEditor<Value: Equatable, ValueContent: View, ValuePicker: View, ActionAreaContent: View>: View {
  30. fileprivate enum PresentedAlert {
  31. case saveConfirmation(AlertContent)
  32. case saveError(Error)
  33. }
  34. var title: Text
  35. var description: Text
  36. var initialScheduleItems: [RepeatingScheduleValue<Value>]
  37. @Binding var scheduleItems: [RepeatingScheduleValue<Value>]
  38. var defaultFirstScheduleItemValue: Value
  39. var scheduleItemLimit: Int
  40. var saveConfirmation: SaveConfirmation
  41. var valueContent: (_ value: Value, _ isEditing: Bool) -> ValueContent
  42. var valuePicker: (_ item: Binding<RepeatingScheduleValue<Value>>, _ availableWidth: CGFloat) -> ValuePicker
  43. var actionAreaContent: ActionAreaContent
  44. var savingMechanism: SavingMechanism<[RepeatingScheduleValue<Value>]>
  45. var mode: SettingsPresentationMode
  46. var therapySettingType: TherapySetting
  47. var hasUnsupportedValue: ([RepeatingScheduleValue<Value>]) -> Bool
  48. @State var editingIndex: Int?
  49. @State var isAddingNewItem = false {
  50. didSet {
  51. if isAddingNewItem {
  52. editingIndex = nil
  53. }
  54. }
  55. }
  56. @State var tableDeletionState: TableDeletionState = .disabled {
  57. didSet {
  58. if tableDeletionState == .enabled {
  59. editingIndex = nil
  60. }
  61. }
  62. }
  63. @State var isSyncing = false
  64. @State private var presentedAlert: PresentedAlert?
  65. @Environment(\.dismissAction) var dismiss
  66. @Environment(\.authenticate) var authenticate
  67. @Environment(\.presentationMode) var presentationMode
  68. init(
  69. title: Text,
  70. description: Text,
  71. scheduleItems: Binding<[RepeatingScheduleValue<Value>]>,
  72. initialScheduleItems: [RepeatingScheduleValue<Value>],
  73. defaultFirstScheduleItemValue: Value,
  74. scheduleItemLimit: Int = 48,
  75. saveConfirmation: SaveConfirmation,
  76. @ViewBuilder valueContent: @escaping (_ value: Value, _ isEditing: Bool) -> ValueContent,
  77. @ViewBuilder valuePicker: @escaping (_ item: Binding<RepeatingScheduleValue<Value>>, _ availableWidth: CGFloat) -> ValuePicker,
  78. @ViewBuilder actionAreaContent: () -> ActionAreaContent,
  79. savingMechanism: SavingMechanism<[RepeatingScheduleValue<Value>]>,
  80. mode: SettingsPresentationMode = .settings,
  81. therapySettingType: TherapySetting = .none,
  82. hasUnsupportedValue: @escaping ([RepeatingScheduleValue<Value>]) -> Bool = { _ in false }
  83. ) {
  84. self.title = title
  85. self.description = description
  86. self.initialScheduleItems = initialScheduleItems
  87. self._scheduleItems = scheduleItems
  88. self.defaultFirstScheduleItemValue = defaultFirstScheduleItemValue
  89. self.scheduleItemLimit = scheduleItemLimit
  90. self.saveConfirmation = saveConfirmation
  91. self.valueContent = valueContent
  92. self.valuePicker = valuePicker
  93. self.actionAreaContent = actionAreaContent()
  94. self.savingMechanism = savingMechanism
  95. self.mode = mode
  96. self.therapySettingType = therapySettingType
  97. self.hasUnsupportedValue = hasUnsupportedValue
  98. }
  99. var body: some View {
  100. ZStack {
  101. configurationPage
  102. .disabled(isSyncing || isAddingNewItem)
  103. .zIndex(0)
  104. if isAddingNewItem {
  105. DarkenedOverlay()
  106. .zIndex(1)
  107. NewScheduleItemEditor(
  108. isPresented: $isAddingNewItem,
  109. initialItem: initialNewScheduleItem,
  110. selectableTimes: scheduleItems.isEmpty
  111. ? .only(.hours(0))
  112. : .allExcept(Set(scheduleItems.map { $0.startTime })),
  113. valuePicker: valuePicker,
  114. onSave: { newItem in
  115. self.scheduleItems.append(newItem)
  116. self.scheduleItems.sort(by: { $0.startTime < $1.startTime })
  117. }
  118. )
  119. .transition(
  120. AnyTransition
  121. .move(edge: .bottom)
  122. .combined(with: .opacity)
  123. )
  124. .zIndex(2)
  125. }
  126. }
  127. }
  128. @ViewBuilder
  129. private var configurationPage: some View {
  130. switch mode {
  131. case .acceptanceFlow:
  132. page
  133. .navigationBarBackButtonHidden(true)
  134. .toolbar {
  135. ToolbarItem(placement: .navigationBarLeading) {
  136. backButton
  137. }
  138. ToolbarItem(placement: .navigationBarTrailing) {
  139. trailingNavigationItems
  140. }
  141. }
  142. case .settings:
  143. page
  144. .navigationBarBackButtonHidden(shouldAddCancelButton)
  145. .toolbar {
  146. ToolbarItem(placement: .navigationBarLeading) {
  147. leadingNavigationBarItem
  148. }
  149. ToolbarItem(placement: .navigationBarTrailing) {
  150. trailingNavigationItems
  151. }
  152. }
  153. .navigationBarTitle("", displayMode: .inline)
  154. }
  155. }
  156. private var shouldAddCancelButton: Bool {
  157. switch saveButtonState {
  158. case .disabled, .loading:
  159. return false
  160. case .enabled:
  161. return true
  162. }
  163. }
  164. @ViewBuilder
  165. private var leadingNavigationBarItem: some View {
  166. if shouldAddCancelButton {
  167. cancelButton
  168. } else {
  169. EmptyView()
  170. }
  171. }
  172. private var page: some View {
  173. ConfigurationPage(
  174. title: title,
  175. actionButtonTitle: Text(mode.buttonText(isSaving: isSyncing)),
  176. actionButtonState: saveButtonState,
  177. cards: {
  178. Card {
  179. SettingDescription(text: description, informationalContent: {self.therapySettingType.helpScreen()})
  180. Splat(Array(scheduleItems.enumerated()), id: \.element.startTime) { index, item in
  181. self.itemView(for: item, at: index)
  182. }
  183. }
  184. },
  185. actionAreaContent: {
  186. unsupportedValueWarningIfNecessary
  187. actionAreaContent
  188. },
  189. action: {
  190. switch self.saveConfirmation {
  191. case .required(let alertContent):
  192. self.presentedAlert = .saveConfirmation(alertContent)
  193. case .notRequired:
  194. self.startSaving()
  195. }
  196. }
  197. )
  198. .alert(item: $presentedAlert, content: alert(for:))
  199. }
  200. private var saveButtonState: ConfigurationPageActionButtonState {
  201. if isSyncing {
  202. return .loading
  203. }
  204. let isEnabled = !scheduleItems.isEmpty
  205. && (scheduleItems != initialScheduleItems || mode == .acceptanceFlow)
  206. && tableDeletionState == .disabled
  207. && !hasUnsupportedValue(scheduleItems)
  208. return isEnabled ? .enabled : .disabled
  209. }
  210. private var unsupportedValueWarningIfNecessary: some View {
  211. return Group {
  212. if hasUnsupportedValue(scheduleItems) {
  213. unsupportedValueWarning
  214. }
  215. }
  216. }
  217. private var unsupportedValueWarning: some View {
  218. WarningView(title: Text("Unsupported \(title)"),
  219. caption: Text(LocalizedString("Correct the highlighted unsupported value(s).", comment: "Instruction to correct unsupported value")))
  220. }
  221. private func itemView(for item: RepeatingScheduleValue<Value>, at index: Int) -> some View {
  222. Deletable(
  223. tableDeletionState: $tableDeletionState,
  224. index: index,
  225. isDeletable: index != 0,
  226. onDelete: {
  227. withAnimation {
  228. self.scheduleItems.remove(at: index)
  229. if self.scheduleItems.count == 1 {
  230. self.tableDeletionState = .disabled
  231. }
  232. }
  233. }
  234. ) {
  235. ScheduleItemView(
  236. time: item.startTime,
  237. isEditing: isEditing(index),
  238. valueContent: {
  239. valueContent(item.value, isEditing(index).wrappedValue)
  240. },
  241. expandedContent: {
  242. ScheduleItemPicker(
  243. item: $scheduleItems[index],
  244. isTimeSelectable: { self.isTimeSelectable($0, at: index) },
  245. valuePicker: { self.valuePicker(self.$scheduleItems[index], $0) }
  246. )
  247. }
  248. )
  249. }
  250. .accessibility(identifier: "schedule_item_\(index)")
  251. }
  252. private func isEditing(_ index: Int) -> Binding<Bool> {
  253. Binding(
  254. get: { index == self.editingIndex },
  255. set: { isNowEditing in
  256. self.editingIndex = isNowEditing ? index : nil
  257. }
  258. )
  259. }
  260. private func isTimeSelectable(_ time: TimeInterval, at index: Int) -> Bool {
  261. if index == scheduleItems.startIndex {
  262. return time == .hours(0)
  263. }
  264. let priorTime = scheduleItems[index - 1].startTime
  265. guard time > priorTime else {
  266. return false
  267. }
  268. if index < scheduleItems.endIndex - 1 {
  269. let nextTime = scheduleItems[index + 1].startTime
  270. guard time < nextTime else {
  271. return false
  272. }
  273. }
  274. return true
  275. }
  276. private var initialNewScheduleItem: RepeatingScheduleValue<Value> {
  277. assert(scheduleItems.count <= scheduleItemLimit)
  278. if scheduleItems.isEmpty {
  279. return RepeatingScheduleValue(startTime: .hours(0), value: defaultFirstScheduleItemValue)
  280. }
  281. if scheduleItems.last!.startTime == .hours(23.5) {
  282. let firstItemFollowedByOpening = scheduleItems.adjacentPairs().first(where: { item, next in
  283. next.startTime - item.startTime > .minutes(30)
  284. })!.0
  285. return RepeatingScheduleValue(
  286. startTime: firstItemFollowedByOpening.startTime + .minutes(30),
  287. value: firstItemFollowedByOpening.value
  288. )
  289. } else {
  290. return RepeatingScheduleValue(
  291. startTime: scheduleItems.last!.startTime + .minutes(30),
  292. value: scheduleItems.last!.value
  293. )
  294. }
  295. }
  296. private var trailingNavigationItems: some View {
  297. // TODO: SwiftUI's alignment of these buttons in the navigation bar is a little funky.
  298. // Tapping 'Edit' then 'Done' can shift '+' slightly.
  299. HStack(spacing: 24) {
  300. if tableDeletionState == .disabled {
  301. editButton
  302. } else {
  303. doneButton
  304. }
  305. addButton
  306. }
  307. }
  308. private var backButton: some View {
  309. Button(action: { presentationMode.wrappedValue.dismiss() }) {
  310. HStack {
  311. Image(systemName: "chevron.left")
  312. .resizable()
  313. .frame(width: 12, height: 20)
  314. Text(backButtonTitle)
  315. .fontWeight(.regular)
  316. }
  317. .offset(x: -6, y: 0)
  318. }
  319. }
  320. private var backButtonTitle: String { LocalizedString("Back", comment: "Back navigation button title") }
  321. var cancelButton: some View {
  322. Button(action: { self.dismiss() } ) { Text(LocalizedString("Cancel", comment: "Cancel editing settings button title")) }
  323. }
  324. var editButton: some View {
  325. Button(
  326. action: {
  327. withAnimation {
  328. self.tableDeletionState = .enabled
  329. }
  330. },
  331. label: {
  332. Text(LocalizedString("Edit", comment: "Text for edit button"))
  333. }
  334. )
  335. .disabled(scheduleItems.count == 1)
  336. }
  337. var doneButton: some View {
  338. Button(
  339. action: {
  340. withAnimation {
  341. self.tableDeletionState = .disabled
  342. }
  343. },
  344. label: {
  345. Text(LocalizedString("Done", comment: "Text for done button")).bold()
  346. }
  347. )
  348. }
  349. var addButton: some View {
  350. Button(
  351. action: {
  352. withAnimation {
  353. self.isAddingNewItem = true
  354. }
  355. },
  356. label: {
  357. Image(systemName: "plus")
  358. .imageScale(.large)
  359. .contentShape(Rectangle())
  360. }
  361. )
  362. .disabled(tableDeletionState != .disabled || scheduleItems.count >= scheduleItemLimit)
  363. }
  364. private func startSaving() {
  365. guard mode == .settings else {
  366. self.continueSaving()
  367. return
  368. }
  369. authenticate(therapySettingType.authenticationChallengeDescription) {
  370. switch $0 {
  371. case .success: self.continueSaving()
  372. case .failure: break
  373. }
  374. }
  375. }
  376. private func continueSaving() {
  377. switch savingMechanism {
  378. case .synchronous(let save):
  379. save(scheduleItems)
  380. case .asynchronous(let save):
  381. withAnimation {
  382. self.editingIndex = nil
  383. self.isSyncing = true
  384. }
  385. save(scheduleItems) { error in
  386. DispatchQueue.main.async {
  387. if let error = error {
  388. withAnimation {
  389. self.isSyncing = false
  390. }
  391. self.presentedAlert = .saveError(error)
  392. }
  393. }
  394. }
  395. }
  396. }
  397. private func alert(for presentedAlert: PresentedAlert) -> SwiftUI.Alert {
  398. switch presentedAlert {
  399. case .saveConfirmation(let content):
  400. return Alert(
  401. title: content.title,
  402. message: content.message,
  403. primaryButton: .cancel(Text(LocalizedString("Go Back", comment: "Button text to return to editing a schedule after from alert popup when some schedule values are outside the recommended range"))),
  404. secondaryButton: .default(
  405. Text(LocalizedString("Continue", comment: "Button text to confirm saving from alert popup when some schedule values are outside the recommended range")),
  406. action: startSaving
  407. )
  408. )
  409. case .saveError(let error):
  410. return Alert(
  411. title: Text(LocalizedString("Unable to Save", comment: "Alert title when error occurs while saving a schedule")),
  412. message: Text(error.localizedDescription)
  413. )
  414. }
  415. }
  416. }
  417. struct DarkenedOverlay: View {
  418. var body: some View {
  419. Rectangle()
  420. .fill(Color.black.opacity(0.3))
  421. .edgesIgnoringSafeArea(.all)
  422. }
  423. }
  424. extension ScheduleEditor.PresentedAlert: Identifiable {
  425. var id: Int {
  426. switch self {
  427. case .saveConfirmation:
  428. return 0
  429. case .saveError:
  430. return 1
  431. }
  432. }
  433. }