ScheduleEditor.swift 15 KB

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