| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467 |
- //
- // ScheduleEditor.swift
- // LoopKitUI
- //
- // Created by Michael Pangburn on 4/24/20.
- // Copyright © 2020 LoopKit Authors. All rights reserved.
- //
- import SwiftUI
- import HealthKit
- import LoopKit
- enum SavingMechanism<Value> {
- case synchronous((_ value: Value) -> Void)
- case asynchronous((_ value: Value, _ completion: @escaping (Error?) -> Void) -> Void)
- func pullback<NewValue>(_ transform: @escaping (NewValue) -> Value) -> SavingMechanism<NewValue> {
- switch self {
- case .synchronous(let save):
- return .synchronous { newValue in save(transform(newValue)) }
- case .asynchronous(let save):
- return .asynchronous { newValue, completion in
- save(transform(newValue), completion)
- }
- }
- }
- }
- enum SaveConfirmation {
- case required(AlertContent)
- case notRequired
- }
- struct ScheduleEditor<Value: Equatable, ValueContent: View, ValuePicker: View, ActionAreaContent: View>: View {
- fileprivate enum PresentedAlert {
- case saveConfirmation(AlertContent)
- case saveError(Error)
- }
- var title: Text
- var description: Text
- var initialScheduleItems: [RepeatingScheduleValue<Value>]
- @Binding var scheduleItems: [RepeatingScheduleValue<Value>]
- var defaultFirstScheduleItemValue: Value
- var scheduleItemLimit: Int
- var saveConfirmation: SaveConfirmation
- var valueContent: (_ value: Value, _ isEditing: Bool) -> ValueContent
- var valuePicker: (_ item: Binding<RepeatingScheduleValue<Value>>, _ availableWidth: CGFloat) -> ValuePicker
- var actionAreaContent: ActionAreaContent
- var savingMechanism: SavingMechanism<[RepeatingScheduleValue<Value>]>
- var mode: SettingsPresentationMode
- var therapySettingType: TherapySetting
-
- @State var editingIndex: Int?
- @State var isAddingNewItem = false {
- didSet {
- if isAddingNewItem {
- editingIndex = nil
- }
- }
- }
- @State var tableDeletionState: TableDeletionState = .disabled {
- didSet {
- if tableDeletionState == .enabled {
- editingIndex = nil
- }
- }
- }
- @State var isSyncing = false
- @State private var presentedAlert: PresentedAlert?
- @Environment(\.dismissAction) var dismiss
- @Environment(\.authenticate) var authenticate
- @Environment(\.presentationMode) var presentationMode
- init(
- title: Text,
- description: Text,
- scheduleItems: Binding<[RepeatingScheduleValue<Value>]>,
- initialScheduleItems: [RepeatingScheduleValue<Value>],
- defaultFirstScheduleItemValue: Value,
- scheduleItemLimit: Int = 48,
- saveConfirmation: SaveConfirmation,
- @ViewBuilder valueContent: @escaping (_ value: Value, _ isEditing: Bool) -> ValueContent,
- @ViewBuilder valuePicker: @escaping (_ item: Binding<RepeatingScheduleValue<Value>>, _ availableWidth: CGFloat) -> ValuePicker,
- @ViewBuilder actionAreaContent: () -> ActionAreaContent,
- savingMechanism: SavingMechanism<[RepeatingScheduleValue<Value>]>,
- mode: SettingsPresentationMode = .settings,
- therapySettingType: TherapySetting = .none
- ) {
- self.title = title
- self.description = description
- self.initialScheduleItems = initialScheduleItems
- self._scheduleItems = scheduleItems
- self.defaultFirstScheduleItemValue = defaultFirstScheduleItemValue
- self.scheduleItemLimit = scheduleItemLimit
- self.saveConfirmation = saveConfirmation
- self.valueContent = valueContent
- self.valuePicker = valuePicker
- self.actionAreaContent = actionAreaContent()
- self.savingMechanism = savingMechanism
- self.mode = mode
- self.therapySettingType = therapySettingType
- }
- var body: some View {
- ZStack {
- configurationPage
- .disabled(isSyncing || isAddingNewItem)
- .zIndex(0)
- if isAddingNewItem {
- DarkenedOverlay()
- .zIndex(1)
- NewScheduleItemEditor(
- isPresented: $isAddingNewItem,
- initialItem: initialNewScheduleItem,
- selectableTimes: scheduleItems.isEmpty
- ? .only(.hours(0))
- : .allExcept(Set(scheduleItems.map { $0.startTime })),
- valuePicker: valuePicker,
- onSave: { newItem in
- self.scheduleItems.append(newItem)
- self.scheduleItems.sort(by: { $0.startTime < $1.startTime })
- }
- )
- .transition(
- AnyTransition
- .move(edge: .bottom)
- .combined(with: .opacity)
- )
- .zIndex(2)
- }
- }
- }
-
- @ViewBuilder
- private var configurationPage: some View {
- switch mode {
- case .acceptanceFlow:
- page
- .navigationBarBackButtonHidden(true)
- .toolbar {
- ToolbarItem(placement: .navigationBarLeading) {
- backButton
- }
- ToolbarItem(placement: .navigationBarTrailing) {
- trailingNavigationItems
- }
- }
- case .settings:
- page
- .navigationBarBackButtonHidden(shouldAddCancelButton)
- .toolbar {
- ToolbarItem(placement: .navigationBarLeading) {
- leadingNavigationBarItem
- }
- ToolbarItem(placement: .navigationBarTrailing) {
- trailingNavigationItems
- }
- }
- .navigationBarTitle("", displayMode: .inline)
- }
- }
- private var shouldAddCancelButton: Bool {
- switch saveButtonState {
- case .disabled, .loading:
- return false
- case .enabled:
- return true
- }
- }
- @ViewBuilder
- private var leadingNavigationBarItem: some View {
- if shouldAddCancelButton {
- cancelButton
- } else {
- EmptyView()
- }
- }
-
- private var page: some View {
- ConfigurationPage(
- title: title,
- actionButtonTitle: Text(mode.buttonText(isSaving: isSyncing)),
- actionButtonState: saveButtonState,
- cards: {
- Card {
- SettingDescription(text: description, informationalContent: {self.therapySettingType.helpScreen()})
- Splat(Array(scheduleItems.enumerated()), id: \.element.startTime) { index, item in
- self.itemView(for: item, at: index)
- }
- }
- },
- actionAreaContent: {
- actionAreaContent
- },
- action: {
- switch self.saveConfirmation {
- case .required(let alertContent):
- self.presentedAlert = .saveConfirmation(alertContent)
- case .notRequired:
- self.startSaving()
- }
- }
- )
- .alert(item: $presentedAlert, content: alert(for:))
- }
- private var saveButtonState: ConfigurationPageActionButtonState {
- if isSyncing {
- return .loading
- }
- let isEnabled = !scheduleItems.isEmpty
- && (scheduleItems != initialScheduleItems || mode == .acceptanceFlow)
- && tableDeletionState == .disabled
- return isEnabled ? .enabled : .disabled
- }
- private func itemView(for item: RepeatingScheduleValue<Value>, at index: Int) -> some View {
- Deletable(
- tableDeletionState: $tableDeletionState,
- index: index,
- isDeletable: index != 0,
- onDelete: {
- withAnimation {
- self.scheduleItems.remove(at: index)
- if self.scheduleItems.count == 1 {
- self.tableDeletionState = .disabled
- }
- }
- }
- ) {
- ScheduleItemView(
- time: item.startTime,
- isEditing: isEditing(index),
- valueContent: {
- valueContent(item.value, isEditing(index).wrappedValue)
- },
- expandedContent: {
- ScheduleItemPicker(
- item: $scheduleItems[index],
- isTimeSelectable: { self.isTimeSelectable($0, at: index) },
- valuePicker: { self.valuePicker(self.$scheduleItems[index], $0) }
- )
- }
- )
- }
- .accessibility(identifier: "schedule_item_\(index)")
- }
- private func isEditing(_ index: Int) -> Binding<Bool> {
- Binding(
- get: { index == self.editingIndex },
- set: { isNowEditing in
- self.editingIndex = isNowEditing ? index : nil
- }
- )
- }
- private func isTimeSelectable(_ time: TimeInterval, at index: Int) -> Bool {
- if index == scheduleItems.startIndex {
- return time == .hours(0)
- }
- let priorTime = scheduleItems[index - 1].startTime
- guard time > priorTime else {
- return false
- }
- if index < scheduleItems.endIndex - 1 {
- let nextTime = scheduleItems[index + 1].startTime
- guard time < nextTime else {
- return false
- }
- }
- return true
- }
- private var initialNewScheduleItem: RepeatingScheduleValue<Value> {
- assert(scheduleItems.count <= scheduleItemLimit)
- if scheduleItems.isEmpty {
- return RepeatingScheduleValue(startTime: .hours(0), value: defaultFirstScheduleItemValue)
- }
- if scheduleItems.last!.startTime == .hours(23.5) {
- let firstItemFollowedByOpening = scheduleItems.adjacentPairs().first(where: { item, next in
- next.startTime - item.startTime > .minutes(30)
- })!.0
- return RepeatingScheduleValue(
- startTime: firstItemFollowedByOpening.startTime + .minutes(30),
- value: firstItemFollowedByOpening.value
- )
- } else {
- return RepeatingScheduleValue(
- startTime: scheduleItems.last!.startTime + .minutes(30),
- value: scheduleItems.last!.value
- )
- }
- }
- private var trailingNavigationItems: some View {
- // TODO: SwiftUI's alignment of these buttons in the navigation bar is a little funky.
- // Tapping 'Edit' then 'Done' can shift '+' slightly.
- HStack(spacing: 24) {
- if tableDeletionState == .disabled {
- editButton
- } else {
- doneButton
- }
- addButton
- }
- }
- private var backButton: some View {
- Button(action: { presentationMode.wrappedValue.dismiss() }) {
- HStack {
- Image(systemName: "chevron.left")
- .resizable()
- .frame(width: 12, height: 20)
- Text(backButtonTitle)
- .fontWeight(.regular)
- }
- .offset(x: -6, y: 0)
- }
- }
- private var backButtonTitle: String { LocalizedString("Back", comment: "Back navigation button title") }
- var cancelButton: some View {
- Button(action: { self.dismiss() } ) { Text(LocalizedString("Cancel", comment: "Cancel editing settings button title")) }
- }
- var editButton: some View {
- Button(
- action: {
- withAnimation {
- self.tableDeletionState = .enabled
- }
- },
- label: {
- Text(LocalizedString("Edit", comment: "Text for edit button"))
- }
- )
- .disabled(scheduleItems.count == 1)
- }
- var doneButton: some View {
- Button(
- action: {
- withAnimation {
- self.tableDeletionState = .disabled
- }
- },
- label: {
- Text(LocalizedString("Done", comment: "Text for done button")).bold()
- }
- )
- }
- var addButton: some View {
- Button(
- action: {
- withAnimation {
- self.isAddingNewItem = true
- }
- },
- label: {
- Image(systemName: "plus")
- .imageScale(.large)
- .contentShape(Rectangle())
- }
- )
- .disabled(tableDeletionState != .disabled || scheduleItems.count >= scheduleItemLimit)
- }
- private func startSaving() {
- guard mode == .settings else {
- self.continueSaving()
- return
- }
-
- authenticate(therapySettingType.authenticationChallengeDescription) {
- switch $0 {
- case .success: self.continueSaving()
- case .failure: break
- }
- }
- }
-
- private func continueSaving() {
- switch savingMechanism {
- case .synchronous(let save):
- save(scheduleItems)
- case .asynchronous(let save):
- withAnimation {
- self.editingIndex = nil
- self.isSyncing = true
- }
- save(scheduleItems) { error in
- DispatchQueue.main.async {
- if let error = error {
- withAnimation {
- self.isSyncing = false
- }
- self.presentedAlert = .saveError(error)
- }
- }
- }
- }
- }
- private func alert(for presentedAlert: PresentedAlert) -> SwiftUI.Alert {
- switch presentedAlert {
- case .saveConfirmation(let content):
- return Alert(
- title: content.title,
- message: content.message,
- 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"))),
- secondaryButton: .default(
- Text(LocalizedString("Continue", comment: "Button text to confirm saving from alert popup when some schedule values are outside the recommended range")),
- action: startSaving
- )
- )
- case .saveError(let error):
- return Alert(
- title: Text(LocalizedString("Unable to Save", comment: "Alert title when error occurs while saving a schedule")),
- message: Text(error.localizedDescription)
- )
- }
- }
- }
- struct DarkenedOverlay: View {
- var body: some View {
- Rectangle()
- .fill(Color.black.opacity(0.3))
- .edgesIgnoringSafeArea(.all)
- }
- }
- extension ScheduleEditor.PresentedAlert: Identifiable {
- var id: Int {
- switch self {
- case .saveConfirmation:
- return 0
- case .saveError:
- return 1
- }
- }
- }
|