From 29471dcd49027e341e29cb6fbbf0f6a7817ef99f Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Thu, 7 Sep 2023 15:10:43 -0700 Subject: [PATCH 1/5] [LOOP-4721] UI Updates for Loop Alert Management --- .../hardware.imageset/Contents.json | 12 + .../hardware.imageset/Group 3403.pdf | Bin 0 -> 9522 bytes .../phone.imageset/Contents.json | 12 + .../phone.imageset/Group 3405.pdf | Bin 0 -> 1891 bytes Loop/Views/AlertManagementView.swift | 73 +++++- Loop/Views/HowMuteAlertWorkView.swift | 142 +++++++++--- Loop/Views/SettingsView.swift | 219 ++++++++++++------ 7 files changed, 349 insertions(+), 109 deletions(-) create mode 100644 Loop/DefaultAssets.xcassets/hardware.imageset/Contents.json create mode 100644 Loop/DefaultAssets.xcassets/hardware.imageset/Group 3403.pdf create mode 100644 Loop/DefaultAssets.xcassets/phone.imageset/Contents.json create mode 100644 Loop/DefaultAssets.xcassets/phone.imageset/Group 3405.pdf diff --git a/Loop/DefaultAssets.xcassets/hardware.imageset/Contents.json b/Loop/DefaultAssets.xcassets/hardware.imageset/Contents.json new file mode 100644 index 0000000000..579e60790c --- /dev/null +++ b/Loop/DefaultAssets.xcassets/hardware.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Group 3403.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/hardware.imageset/Group 3403.pdf b/Loop/DefaultAssets.xcassets/hardware.imageset/Group 3403.pdf new file mode 100644 index 0000000000000000000000000000000000000000..14057221edafce035c4c3188bdadf5d3fa536df1 GIT binary patch literal 9522 zcmeHNO^+ML5xvi^=!*b5fM@&r126={l57M?5M>o02P20KMM)cwyYVg^$ocho)tq_N zOA?lR%Rv;N9X@tfy{fLNuIZU)uU~%ujho9fIb+TJKmRou^X<3h>ea`?4{r`Phwbs3 z?|(Ko#?Hyg$2@!7P1oBiyiftxkujp=C?JUE5^-`ps0V5;G zyx`B>^kMq@@1{F(_i8T<|AtrmO=T#r&3>|T=pTeqbgtxtix!*p);wG^E%jb8nh852(dKqBB=Ahi}Obm+UQ`?-LG_=Ax0CN`DOrh z_GNS!`eEW1lHDsdvQk->QO@7#`~mxLf&YFAbBMvktTvI)@>9{DC~Y$-8G^e3bh8W5 z+G3WAI{0UqFuDvGd|cfpz8x2xTE;bB7$y_roKvnTC|6wr8nhXfRr&WIs5!M3Mv{yn zT5ut(Advw?hLU`P_AxhI^Q7T+SO5^$uXq^``Fn_ty#8O6zjShL5ZB(m+ zQKOHGnvH_$M$GRz{oq99xzUhRaEQ~@c9c*CBwa)@%8>zM2QuRLcPM&)#lO!je9ZIY z*X{Dtg^vrt0y;(|!Lht>$_`a^+5dPkV8i6qn4c!us^33;W+|sdkz3_&v546MlUAa- zJ-{S_jQriirE(X+!B7?DE}|ny{ue>oi7?_dFGg_SL`j!0<))GJnQ;`TIMEls!Rw6h z{X}BKb|})n5;4O#jTlPtw0IDWHl#OJ&D6ESaunkjf{LL~w3UOR*!|FsF1;G(CyfxQ zQB3(Dg*g&E&SD{CD(YA1s~%K(>QP3Dp&MG*yJ4inXScTuAUzbRMIq8b8@Mu4=AYSJ zp;uoU-SuK%D&1Ag_!Kl%A;)4fE=tR`)GytYIL>_OuB>6Wf9bAzG9f6wbXP}DXe0H1 z)?I1ggHRcH`8^Fu@sjFh?>Wn?sk-sj7rlk zMAa&W$>}H?bGRw!4yXc|r=wzmN|55z4DK+5Hi&T?3M_WA4T2X4X3`3)QIzA@FA(a- z1l>u=5Y&E0rLp~^);^U~`Xay?e$`LXs+AC?k{v;{p{&eDXwlSNuzG;@$UVryfEXEv zl@bwpYeLVgnlQe&pPJH$I>Jn0P zX9Q{faFc|a8_6E@PwNGBQXi2G8qURPV!WWF0io5z$cmC^4fPQbt2I1U3-^lAb&MFg zmtjgoTDFBfF@`)P5gAwv*y1fpqED%h$YvrdVuLk{qyeF~kmO`Y&mqKdaC=-Vf@@cc zyWQ0`h3HVE8?E6wt27N6Y9+Lp+A5eBD;Z@C2#p{{W|XA#sgRJYxpvg_DvrK5mdYR1i%O`L8Q{SvZ|F3D&&&0SxX(DML$DeglLC?3<8y=Hk{ikQSeUA zspY`t>XrZ0DTj6zdcz&C*eUxG&8J%nxzozo;{;1m%vEdTHq4b;vjbKV8WS8~0SYBH zoX6t^6eoin3Zb>qWG06DVsHkEM2$Z7si8TG3xQI^7mTw7H+4~H#b)TZsl-vm`cN)t)HFG z7At_n0jFAuoG*tEOY#^MTy{Z53o$y2yk1iKwxXm z5(hBD!}IWQjdFM_^G-Pkvotr~8f<{H5EGmyM==f$QW75I!9(7*iqaNhZl$}bBG9DR z`P^a>C*c1YM1#z84IaY5i)#oc1VzKcvM4rI_#Ba!<{Y~NFs8*&66T$A5N36TD+0nB z!HO`>5zKH@Qd`E;HxJraD2ImIH6N3eRe=JmZ^b%Q%me}#x+Xr8+8ce62MG?z=;Umv z#Do|FVuSUu0)Za_79V3$b}(P&opKOn*tMF4@#1I1mPK@7W0WY$*ct))90EiGg3jt> z9a9bf1#*ZdJgf?HMN&4gUp7W(ovUboY$_$egE(!0<0DRrBm`_k2q3(X5{MIwLP%O6 zc!5f1h~O$%_yB)HukWdVcGqKh5FQB(OLbU0__k(Hgv?fLkgSj>^vDB11%Hzi6PXHL zMaF>yOptLf*IMb?f^QK|X#iE^v2QX8F2!>9si#yr!-ROt6$ebH@^Zc;DS^8jXJgG3 zpmQO^nShYeu9FTGfrh6xfGkq62UGGD0S7gh8jzN9D1p!r5O+c~2WXL<8LkZQA?ig# z9`*YS2S-bA$hZ?KogtnXRM;$Iv8+frVOSAnaAF5-Ml>4PimgLb9&;kVVFEd%ZBKv} z3sC4HQY6Zz@1zi% z=Ri-4(`@PQ1JvZCc*qpQ05SkID$2eM2rGs*LQ27D)d^Y-oS z&3E@7&5y`~r3KAZ2fIqT-ag*{{IJt4@%-`jEvOo?;M%NuUflnTx$cbrBalH79vlpk#K1=?#A`o`;V*6uH@o}y zPp5u-*uI;rDbvw>`QPLu4HDqxBgITU=5B?g;d~0AV}UE4jH(UG{uFAc6Q>Y%GeU=l zo7>&?VUo9+{_qm+`0DX~zx~>Lb^G?~qf@S~@9z&r4DJrTdUN~VE!g*Wk8U0g-JFJo Mb9(md)nC5fjN5Qgvm6?3W79_rX0f5cK%iEb%EfLONNDh?r=wu`n2Y*JMC^^A9uacB?V zL*;#9&%86X-ySV5uTHfLLTFIXef=f?&d%WcTr}-E{S;=-i}y`?H{1gfT%}e0uxqLZ zQCv1ZH+8dma{-I%`LDVcKZKTQKR`{BQ=Yy2#-Hj3p(J`6l&76GSf>^6`o3=I^B6ct zZC%tJ(w31KY0(E0fhOszg)yr&kk%U){0!B?I9WPd;+$>;D(P&{M$gtFzDSZ0YpF$s zPD>lDEw3O$Y|g1fW|AFQ7R=$ zN6e@hMJ?&HFc8aGGGjT^ai%Ps8qN?UMi)yBISUn_EGnDYJ<4H}x;+YKM&YLVn9=0W zEsoEGWz^KwThWS7|N9`EbU~FifVI*aU!sP8zX&6PQ_ANn9MZu>Nr&Yr!1MwFot#m! z)MfByMvbN?;6gCzouMyFdOeI%Y-iB{9UVg_eHolKDImijt#XlWK+aK0YfX#@SqvkJ zH_2v}ZWbwsh`@~RFUM!XGG-T=^=|Ve=dw6@ZV8LXC?8t#!_n*}XG|t|a7=BSV#BnI z$w-S=ZQBh!+ZXTe z-K#O&qHBkyrHsk9C%CM3#K4Vc=I#lx>lg1fYmy47f)z}87TqK5)i3n-L}ro5qi2y) zfjuY8g)ge1+IDw;Z++S|w?aV>2j@ some View { + HStack(spacing: 16) { + Image(systemName: "circle.fill") + .resizable() + .frame(width: 8, height: 8) + .foregroundColor(color) + + self + } + } +} + struct HowMuteAlertWorkView_Previews: PreviewProvider { static var previews: some View { HowMuteAlertWorkView() diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 7eadb7f1ec..825ec17820 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -24,13 +24,39 @@ public struct SettingsView: View { @ObservedObject var viewModel: SettingsViewModel @ObservedObject var versionUpdateViewModel: VersionUpdateViewModel - @State private var pumpChooserIsPresented: Bool = false - @State private var cgmChooserIsPresented: Bool = false - @State private var favoriteFoodsIsPresented: Bool = false - @State private var serviceChooserIsPresented: Bool = false - @State private var therapySettingsIsPresented: Bool = false - @State private var deletePumpDataAlertIsPresented = false - @State private var deleteCGMDataAlertIsPresented = false + enum Destination { + enum Alert: String, Identifiable { + var id: String { + rawValue + } + + case deleteCGMData + case deletePumpData + } + + enum ActionSheet: String, Identifiable { + var id: String { + rawValue + } + + case cgmPicker + case pumpPicker + case servicePicker + } + + enum Sheet: String, Identifiable { + var id: String { + rawValue + } + + case favoriteFoods + case therapySettings + } + } + + @State private var actionSheet: Destination.ActionSheet? + @State private var alert: Destination.Alert? + @State private var sheet: Destination.Sheet? var localizedAppNameAndVersion: String @@ -82,6 +108,57 @@ public struct SettingsView: View { .insetGroupedListStyle() .navigationBarTitle(Text(NSLocalizedString("Settings", comment: "Settings screen title"))) .navigationBarItems(trailing: dismissButton) + .actionSheet(item: $actionSheet) { actionSheet in + switch actionSheet { + case .cgmPicker: + ActionSheet( + title: Text("Add CGM", comment: "The title of the CGM chooser in settings"), + buttons: cgmChoices + ) + case .pumpPicker: + ActionSheet( + title: Text("Add Pump", comment: "The title of the pump chooser in settings"), + buttons: pumpChoices + ) + case .servicePicker: + ActionSheet( + title: Text("Add Service", comment: "The title of the add service action sheet in settings"), + buttons: serviceChoices + ) + } + } + .alert(item: $alert) { alert in + switch alert { + case .deleteCGMData: + makeDeleteAlert(for: self.viewModel.cgmManagerSettingsViewModel) + case .deletePumpData: + makeDeleteAlert(for: self.viewModel.pumpManagerSettingsViewModel) + } + } + .sheet(item: $sheet) { sheet in + switch sheet { + case .therapySettings: + TherapySettingsView( + mode: .settings, + viewModel: TherapySettingsViewModel( + therapySettings: viewModel.therapySettings(), + sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, + adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, + delegate: viewModel.therapySettingsViewModelDelegate + ) + ) + .environmentObject(displayGlucosePreference) + .environment(\.dismissAction, self.dismiss) + .environment(\.appName, self.appName) + .environment(\.chartColorPalette, .primary) + .environment(\.carbTintColor, self.carbTintColor) + .environment(\.glucoseTintColor, self.glucoseTintColor) + .environment(\.guidanceColors, self.guidanceColors) + .environment(\.insulinTintColor, self.insulinTintColor) + case .favoriteFoods: + FavoriteFoodsView() + } + } } .navigationViewStyle(.stack) } @@ -177,53 +254,46 @@ extension SettingsView { } } } + + @ViewBuilder + private var alertWarning: some View { + if viewModel.alertPermissionsChecker.showWarning || viewModel.alertPermissionsChecker.notificationCenterSettings.scheduledDeliveryEnabled { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.critical) + } else if viewModel.alertMuter.configuration.shouldMute { + Image(systemName: "speaker.slash.fill") + .foregroundColor(.white) + .padding(5) + .background(guidanceColors.warning) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + } + } private var alertManagementSection: some View { Section { - NavigationLink(destination: AlertManagementView(checker: viewModel.alertPermissionsChecker, alertMuter: viewModel.alertMuter)) - { - HStack { - Text(NSLocalizedString("Alert Management", comment: "Alert Permissions button text")) - if viewModel.alertPermissionsChecker.showWarning || - viewModel.alertPermissionsChecker.notificationCenterSettings.scheduledDeliveryEnabled { - Spacer() - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.critical) - } else if viewModel.alertMuter.configuration.shouldMute { - Spacer() - Image(systemName: "speaker.slash.fill") - .foregroundColor(.white) - .padding(5) - .background(guidanceColors.warning) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) - } - } + NavigationLink(destination: AlertManagementView(checker: viewModel.alertPermissionsChecker, alertMuter: viewModel.alertMuter)) { + LargeButton( + action: {}, + includeArrow: false, + imageView: Image(systemName: "bell.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30), + secondaryImageView: alertWarning, + label: NSLocalizedString("Alert Management", comment: "Alert Permissions button text"), + descriptiveText: NSLocalizedString("Alert Permissions and Mute Alerts", comment: "Alert Permissions descriptive text") + ) } } } private var configurationSection: some View { Section(header: SectionHeader(label: NSLocalizedString("Configuration", comment: "The title of the Configuration section in settings"))) { - LargeButton(action: { self.therapySettingsIsPresented = true }, + LargeButton(action: { sheet = .therapySettings }, includeArrow: true, imageView: Image("Therapy Icon"), label: NSLocalizedString("Therapy Settings", comment: "Title text for button to Therapy Settings"), descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) - .sheet(isPresented: $therapySettingsIsPresented) { - TherapySettingsView(mode: .settings, - viewModel: TherapySettingsViewModel(therapySettings: self.viewModel.therapySettings(), - sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, - adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, - delegate: self.viewModel.therapySettingsViewModelDelegate)) - .environmentObject(displayGlucosePreference) - .environment(\.dismissAction, self.dismiss) - .environment(\.appName, self.appName) - .environment(\.chartColorPalette, .primary) - .environment(\.carbTintColor, self.carbTintColor) - .environment(\.glucoseTintColor, self.glucoseTintColor) - .environment(\.guidanceColors, self.guidanceColors) - .environment(\.insulinTintColor, self.insulinTintColor) - } ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in item.view @@ -259,16 +329,11 @@ extension SettingsView { label: viewModel.pumpManagerSettingsViewModel.name(), descriptiveText: NSLocalizedString("Insulin Pump", comment: "Descriptive text for Insulin Pump")) } else if viewModel.isOnboardingComplete { - LargeButton(action: { self.pumpChooserIsPresented = true }, + LargeButton(action: { actionSheet = .pumpPicker }, includeArrow: false, imageView: plusImage, label: NSLocalizedString("Add Pump", comment: "Title text for button to add pump device"), descriptiveText: NSLocalizedString("Tap here to set up a pump", comment: "Descriptive text for button to add pump device")) - .actionSheet(isPresented: $pumpChooserIsPresented) { - ActionSheet(title: Text("Add Pump", comment: "The title of the pump chooser in settings"), buttons: pumpChoices) - } - } else { - EmptyView() } } @@ -291,28 +356,22 @@ extension SettingsView { label: viewModel.cgmManagerSettingsViewModel.name(), descriptiveText: NSLocalizedString("Continuous Glucose Monitor", comment: "Descriptive text for Continuous Glucose Monitor")) } else { - LargeButton(action: { self.cgmChooserIsPresented = true }, + LargeButton(action: { actionSheet = .cgmPicker }, includeArrow: false, imageView: plusImage, label: NSLocalizedString("Add CGM", comment: "Title text for button to add CGM device"), descriptiveText: NSLocalizedString("Tap here to set up a CGM", comment: "Descriptive text for button to add CGM device")) - .actionSheet(isPresented: $cgmChooserIsPresented) { - ActionSheet(title: Text("Add CGM", comment: "The title of the CGM chooser in settings"), buttons: cgmChoices) - } } } private var favoriteFoodsSection: some View { Section { - LargeButton(action: { self.favoriteFoodsIsPresented = true }, + LargeButton(action: { sheet = .favoriteFoods }, includeArrow: true, imageView: Image("Favorite Foods Icon").renderingMode(.template).foregroundColor(carbTintColor), label: "Favorite Foods", descriptiveText: "Simplify Carb Entry") } - .sheet(isPresented: $favoriteFoodsIsPresented) { - FavoriteFoodsView() - } } private var cgmChoices: [ActionSheet.Button] { @@ -337,14 +396,11 @@ extension SettingsView { descriptiveText: "") } if viewModel.servicesViewModel.inactiveServices().count > 0 { - LargeButton(action: { self.serviceChooserIsPresented = true }, + LargeButton(action: { actionSheet = .servicePicker }, includeArrow: false, imageView: plusImage, label: NSLocalizedString("Add Service", comment: "The title of the add service button in settings"), descriptiveText: NSLocalizedString("Tap here to set up a Service", comment: "The descriptive text of the add service button in settings")) - .actionSheet(isPresented: $serviceChooserIsPresented) { - ActionSheet(title: Text("Add Service", comment: "The title of the add service action sheet in settings"), buttons: serviceChoices) - } } } } @@ -362,28 +418,22 @@ extension SettingsView { private var deleteDataSection: some View { Section { if viewModel.pumpManagerSettingsViewModel.isTestingDevice { - Button(action: { self.deletePumpDataAlertIsPresented.toggle() }) { + Button(action: { alert = .deletePumpData }) { HStack { Spacer() Text("Delete Testing Pump Data").accentColor(.destructive) Spacer() } } - .alert(isPresented: $deletePumpDataAlertIsPresented) { - makeDeleteAlert(for: self.viewModel.pumpManagerSettingsViewModel) - } } if viewModel.cgmManagerSettingsViewModel.isTestingDevice { - Button(action: { self.deleteCGMDataAlertIsPresented.toggle() }) { + Button(action: { alert = .deleteCGMData }) { HStack { Spacer() Text("Delete Testing CGM Data").accentColor(.destructive) Spacer() } } - .alert(isPresented: $deleteCGMDataAlertIsPresented) { - makeDeleteAlert(for: self.viewModel.cgmManagerSettingsViewModel) - } } } } @@ -473,33 +523,60 @@ extension SettingsView { } } -fileprivate struct LargeButton: View { +fileprivate struct LargeButton: View { let action: () -> Void - var includeArrow: Bool = true + var includeArrow: Bool let imageView: Content + let secondaryImageView: SecondaryContent let label: String let descriptiveText: String + + init( + action: @escaping () -> Void, + includeArrow: Bool = true, + imageView: Content, + secondaryImageView: SecondaryContent = EmptyView(), + label: String, + descriptiveText: String + ) { + self.action = action + self.includeArrow = includeArrow + self.imageView = imageView + self.secondaryImageView = secondaryImageView + self.label = label + self.descriptiveText = descriptiveText + } // TODO: The design doesn't show this, but do we need to consider different values here for different size classes? private let spacing: CGFloat = 15 private let imageWidth: CGFloat = 60 private let imageHeight: CGFloat = 60 + private let secondaryImageWidth: CGFloat = 30 + private let secondaryImageHeight: CGFloat = 30 private let topBottomPadding: CGFloat = 10 public var body: some View { Button(action: action) { HStack { HStack(spacing: spacing) { - imageView.frame(width: imageWidth, height: imageHeight) + imageView.frame(maxWidth: imageWidth, maxHeight: imageHeight) VStack(alignment: .leading) { Text(label) .foregroundColor(.primary) DescriptiveText(label: descriptiveText) } } - if includeArrow { + + if !(secondaryImageView is EmptyView) || includeArrow { Spacer() + } + + if !(secondaryImageView is EmptyView) { + secondaryImageView.frame(width: secondaryImageWidth, height: secondaryImageHeight) + } + + if includeArrow { // TODO: Ick. I can't use a NavigationLink because we're not Navigating, but this seems worse somehow. Image(systemName: "chevron.right").foregroundColor(.gray).font(.footnote) } From d76c009fef3b2439f52b8f74defcad42d7656d22 Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 13 Sep 2023 10:56:13 -0700 Subject: [PATCH 2/5] [LOOP-4721] UI Updates for Loop Alert Management --- Loop/Views/SettingsView.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 825ec17820..47cc61041b 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -111,17 +111,17 @@ public struct SettingsView: View { .actionSheet(item: $actionSheet) { actionSheet in switch actionSheet { case .cgmPicker: - ActionSheet( + return ActionSheet( title: Text("Add CGM", comment: "The title of the CGM chooser in settings"), buttons: cgmChoices ) case .pumpPicker: - ActionSheet( + return ActionSheet( title: Text("Add Pump", comment: "The title of the pump chooser in settings"), buttons: pumpChoices ) case .servicePicker: - ActionSheet( + return ActionSheet( title: Text("Add Service", comment: "The title of the add service action sheet in settings"), buttons: serviceChoices ) @@ -130,15 +130,15 @@ public struct SettingsView: View { .alert(item: $alert) { alert in switch alert { case .deleteCGMData: - makeDeleteAlert(for: self.viewModel.cgmManagerSettingsViewModel) + return makeDeleteAlert(for: self.viewModel.cgmManagerSettingsViewModel) case .deletePumpData: - makeDeleteAlert(for: self.viewModel.pumpManagerSettingsViewModel) + return makeDeleteAlert(for: self.viewModel.pumpManagerSettingsViewModel) } } .sheet(item: $sheet) { sheet in switch sheet { case .therapySettings: - TherapySettingsView( + return TherapySettingsView( mode: .settings, viewModel: TherapySettingsViewModel( therapySettings: viewModel.therapySettings(), @@ -156,7 +156,7 @@ public struct SettingsView: View { .environment(\.guidanceColors, self.guidanceColors) .environment(\.insulinTintColor, self.insulinTintColor) case .favoriteFoods: - FavoriteFoodsView() + return FavoriteFoodsView() } } } From 2741c8e50a5f5293fecdd08959ce077d02c039ca Mon Sep 17 00:00:00 2001 From: Cameron Ingham Date: Wed, 13 Sep 2023 10:58:08 -0700 Subject: [PATCH 3/5] [LOOP-4721] UI Updates for Loop Alert Management --- Loop/Views/SettingsView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 47cc61041b..b8e9c1daf7 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -138,7 +138,7 @@ public struct SettingsView: View { .sheet(item: $sheet) { sheet in switch sheet { case .therapySettings: - return TherapySettingsView( + TherapySettingsView( mode: .settings, viewModel: TherapySettingsViewModel( therapySettings: viewModel.therapySettings(), @@ -156,7 +156,7 @@ public struct SettingsView: View { .environment(\.guidanceColors, self.guidanceColors) .environment(\.insulinTintColor, self.insulinTintColor) case .favoriteFoods: - return FavoriteFoodsView() + FavoriteFoodsView() } } } From 974574de7237e5691d5c995985bbd92ec705167a Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Tue, 19 Sep 2023 05:45:56 -0300 Subject: [PATCH 4/5] [COASTAL-1291] added tidepool security plugin (#593) * added tidepool security plugin * refactor to provide security provider to pump manager * response to comments * response to PR comments * response to PR comment * fixed unit tests * all active plugins * corrected typo * corrected file name --- Common/Models/PumpManager.swift | 6 +- Loop.xcodeproj/project.pbxproj | 8 ++ Loop/Managers/AnalyticsServicesManager.swift | 2 +- Loop/Managers/CGMManager.swift | 6 +- Loop/Managers/DeviceDataManager.swift | 98 +++++++++++--- Loop/Managers/LoggingServicesManager.swift | 2 +- Loop/Managers/LoopAppManager.swift | 8 +- Loop/Managers/OnboardingManager.swift | 47 +++++-- Loop/Managers/RemoteDataServicesManager.swift | 24 ++-- Loop/Managers/Service.swift | 17 +-- Loop/Managers/ServicesManager.swift | 31 +++-- Loop/Managers/StatefulPluggable.swift | 20 +++ Loop/Managers/StatefulPluginManager.swift | 125 ++++++++++++++++++ Loop/Managers/SupportManager.swift | 14 +- Loop/Managers/TestingScenariosManager.swift | 8 +- Loop/Plugins/PluginManager.swift | 35 ++++- .../StatusTableViewController.swift | 2 +- Loop/View Models/ServicesViewModel.swift | 14 +- Loop/Views/SettingsView.swift | 2 +- LoopTests/Managers/DoseEnactorTests.swift | 2 +- LoopTests/Managers/SupportManagerTests.swift | 6 +- 21 files changed, 365 insertions(+), 112 deletions(-) create mode 100644 Loop/Managers/StatefulPluggable.swift create mode 100644 Loop/Managers/StatefulPluginManager.swift diff --git a/Common/Models/PumpManager.swift b/Common/Models/PumpManager.swift index 3c2486ef68..5ec574366c 100644 --- a/Common/Models/PumpManager.swift +++ b/Common/Models/PumpManager.swift @@ -12,13 +12,13 @@ import MockKit import MockKitUI let staticPumpManagersByIdentifier: [String: PumpManagerUI.Type] = [ - MockPumpManager.managerIdentifier : MockPumpManager.self + MockPumpManager.pluginIdentifier : MockPumpManager.self ] var availableStaticPumpManagers: [PumpManagerDescriptor] { if FeatureFlags.allowSimulators { return [ - PumpManagerDescriptor(identifier: MockPumpManager.managerIdentifier, localizedTitle: MockPumpManager.localizedTitle) + PumpManagerDescriptor(identifier: MockPumpManager.pluginIdentifier, localizedTitle: MockPumpManager.localizedTitle) ] } else { return [] @@ -31,7 +31,7 @@ extension PumpManager { var rawValue: RawValue { return [ - "managerIdentifier": self.managerIdentifier, + "managerIdentifier": self.pluginIdentifier, "state": self.rawState ] } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index a5426f692c..eab2ef9b28 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -374,6 +374,7 @@ B42D124328D371C400E43D22 /* AlertMuter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42D124228D371C400E43D22 /* AlertMuter.swift */; }; B43CF07E29434EC4008A520B /* HowMuteAlertWorkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */; }; B43DA44124D9C12100CAFF4E /* DismissibleHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43DA44024D9C12100CAFF4E /* DismissibleHostingController.swift */; }; + B470F5842AB22B5100049695 /* StatefulPluggable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B470F5832AB22B5100049695 /* StatefulPluggable.swift */; }; B48B0BAC24900093009A48DE /* PumpStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48B0BAB24900093009A48DE /* PumpStatusHUDView.swift */; }; B490A03F24D0550F00F509FA /* GlucoseRangeCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B490A03E24D0550F00F509FA /* GlucoseRangeCategory.swift */; }; B490A04124D0559D00F509FA /* DeviceLifecycleProgressState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B490A04024D0559D00F509FA /* DeviceLifecycleProgressState.swift */; }; @@ -387,6 +388,7 @@ B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4CAD8782549D2540057946B /* LoopCompletionFreshnessTests.swift */; }; B4D4534128E5CA7900F1A8D9 /* AlertMuterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */; }; B4D620D424D9EDB900043B3C /* GuidanceColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D620D324D9EDB900043B3C /* GuidanceColors.swift */; }; + B4D904412AA8989100CBD826 /* StatefulPluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */; }; B4E202302661063E009421B5 /* AutomaticDosingStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */; }; B4E96D4B248A6B6E002DABAD /* DeviceStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D4A248A6B6E002DABAD /* DeviceStatusHUDView.swift */; }; B4E96D4F248A6E20002DABAD /* CGMStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D4E248A6E20002DABAD /* CGMStatusHUDView.swift */; }; @@ -1297,6 +1299,7 @@ B42D124228D371C400E43D22 /* AlertMuter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertMuter.swift; sourceTree = ""; }; B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowMuteAlertWorkView.swift; sourceTree = ""; }; B43DA44024D9C12100CAFF4E /* DismissibleHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissibleHostingController.swift; sourceTree = ""; }; + B470F5832AB22B5100049695 /* StatefulPluggable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPluggable.swift; sourceTree = ""; }; B48B0BAB24900093009A48DE /* PumpStatusHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpStatusHUDView.swift; sourceTree = ""; }; B490A03C24D04F9400F509FA /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; B490A03E24D0550F00F509FA /* GlucoseRangeCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseRangeCategory.swift; sourceTree = ""; }; @@ -1307,6 +1310,7 @@ B4CAD8782549D2540057946B /* LoopCompletionFreshnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCompletionFreshnessTests.swift; sourceTree = ""; }; B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMuterTests.swift; sourceTree = ""; }; B4D620D324D9EDB900043B3C /* GuidanceColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuidanceColors.swift; sourceTree = ""; }; + B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPluginManager.swift; sourceTree = ""; }; B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticDosingStatus.swift; sourceTree = ""; }; B4E96D4A248A6B6E002DABAD /* DeviceStatusHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusHUDView.swift; sourceTree = ""; }; B4E96D4E248A6E20002DABAD /* CGMStatusHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDView.swift; sourceTree = ""; }; @@ -2293,10 +2297,12 @@ 43C094491CACCC73001F6403 /* NotificationManager.swift */, A97F250725E056D500F0EE19 /* OnboardingManager.swift */, 432E73CA1D24B3D6009AD15D /* RemoteDataServicesManager.swift */, + B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */, A9C62D852331703000535612 /* Service.swift */, A9C62D872331703000535612 /* ServicesManager.swift */, C1F7822527CC056900C0919A /* SettingsManager.swift */, E9BB27AA23B85C3500FB4987 /* SleepStore.swift */, + B470F5832AB22B5100049695 /* StatefulPluggable.swift */, 43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */, 1D63DEA426E950D400F46FA5 /* SupportManager.swift */, 4F70C20F1DE8FAC5006380B7 /* ExtensionDataManager.swift */, @@ -3653,10 +3659,12 @@ B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */, 142CB7592A60BF2E0075748A /* EditMode.swift in Sources */, E95D380324EADF36005E2F50 /* CarbStoreProtocol.swift in Sources */, + B470F5842AB22B5100049695 /* StatefulPluggable.swift in Sources */, E98A55ED24EDD6380008715D /* LatestStoredSettingsProvider.swift in Sources */, C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */, A9B996F227238705002DC09C /* DosingDecisionStore.swift in Sources */, 43C513191E864C4E001547C7 /* GlucoseRangeSchedule.swift in Sources */, + B4D904412AA8989100CBD826 /* StatefulPluginManager.swift in Sources */, E9B355292935919E0076AB04 /* MissedMealSettings.swift in Sources */, 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */, 43776F901B8022E90074EA36 /* AppDelegate.swift in Sources */, diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index 650d74a597..808a34c81a 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -28,7 +28,7 @@ final class AnalyticsServicesManager { } func removeService(_ analyticsService: AnalyticsService) { - analyticsServices.removeAll { $0.serviceIdentifier == analyticsService.serviceIdentifier } + analyticsServices.removeAll { $0.pluginIdentifier == analyticsService.pluginIdentifier } } private func logEvent(_ name: String, withProperties properties: [AnyHashable: Any]? = nil, outOfSession: Bool = false) { diff --git a/Loop/Managers/CGMManager.swift b/Loop/Managers/CGMManager.swift index 041e632288..fe39e3926c 100644 --- a/Loop/Managers/CGMManager.swift +++ b/Loop/Managers/CGMManager.swift @@ -10,13 +10,13 @@ import LoopKitUI import MockKit let staticCGMManagersByIdentifier: [String: CGMManager.Type] = [ - MockCGMManager.managerIdentifier: MockCGMManager.self + MockCGMManager.pluginIdentifier: MockCGMManager.self ] var availableStaticCGMManagers: [CGMManagerDescriptor] { if FeatureFlags.allowSimulators { return [ - CGMManagerDescriptor(identifier: MockCGMManager.managerIdentifier, localizedTitle: MockCGMManager.localizedTitle) + CGMManagerDescriptor(identifier: MockCGMManager.pluginIdentifier, localizedTitle: MockCGMManager.localizedTitle) ] } else { return [] @@ -40,7 +40,7 @@ extension CGMManager { var rawValue: [String: Any] { return [ - "managerIdentifier": managerIdentifier, + "managerIdentifier": pluginIdentifier, "state": self.rawState ] } diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 2e8d157531..2697df7569 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -97,9 +97,9 @@ final class DeviceDataManager { dispatchPrecondition(condition: .onQueue(.main)) setupCGM() - if cgmManager?.managerIdentifier != oldValue?.managerIdentifier { + if cgmManager?.pluginIdentifier != oldValue?.pluginIdentifier { if let cgmManager = cgmManager { - analyticsServicesManager.cgmWasAdded(identifier: cgmManager.managerIdentifier) + analyticsServicesManager.cgmWasAdded(identifier: cgmManager.pluginIdentifier) } else { analyticsServicesManager.cgmWasRemoved() } @@ -125,9 +125,9 @@ final class DeviceDataManager { cgmManager = nil } - if pumpManager?.managerIdentifier != oldValue?.managerIdentifier { + if pumpManager?.pluginIdentifier != oldValue?.pluginIdentifier { if let pumpManager = pumpManager { - analyticsServicesManager.pumpWasAdded(identifier: pumpManager.managerIdentifier) + analyticsServicesManager.pumpWasAdded(identifier: pumpManager.pluginIdentifier) } else { analyticsServicesManager.pumpWasRemoved() } @@ -205,6 +205,8 @@ final class DeviceDataManager { sleepDataAuthorizationRequired } + private(set) var statefulPluginManager: StatefulPluginManager! + // MARK: Services private(set) var servicesManager: ServicesManager! @@ -422,7 +424,9 @@ final class DeviceDataManager { servicesManagerDelegate: loopManager, servicesManagerDosingDelegate: self ) - + + statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, directory: FileManager.default.exportsDirectoryURL, @@ -582,7 +586,7 @@ final class DeviceDataManager { var availableCGMManagers: [CGMManagerDescriptor] { var availableCGMManagers = pluginManager.availableCGMManagers + availableStaticCGMManagers if let pumpManagerAsCGMManager = pumpManager as? CGMManager { - availableCGMManagers.append(CGMManagerDescriptor(identifier: pumpManagerAsCGMManager.managerIdentifier, localizedTitle: pumpManagerAsCGMManager.localizedTitle)) + availableCGMManagers.append(CGMManagerDescriptor(identifier: pumpManagerAsCGMManager.pluginIdentifier, localizedTitle: pumpManagerAsCGMManager.localizedTitle)) } availableCGMManagers = availableCGMManagers.filter({ cgmManager in @@ -635,7 +639,7 @@ final class DeviceDataManager { } public func setupCGMManagerFromPumpManager(withIdentifier identifier: String) -> CGMManager? { - guard identifier == pumpManager?.managerIdentifier, let cgmManager = pumpManager as? CGMManager else { + guard identifier == pumpManager?.pluginIdentifier, let cgmManager = pumpManager as? CGMManager else { return nil } @@ -698,19 +702,20 @@ private extension DeviceDataManager { cgmManager?.cgmManagerDelegate = self cgmManager?.delegateQueue = queue + reportPluginInitializationComplete() glucoseStore.managedDataInterval = cgmManager?.managedDataInterval glucoseStore.healthKitStorageDelay = cgmManager.map{ type(of: $0).healthKitStorageDelay } ?? 0 updatePumpManagerBLEHeartbeatPreference() if let cgmManager = cgmManager { - alertManager?.addAlertResponder(managerIdentifier: cgmManager.managerIdentifier, + alertManager?.addAlertResponder(managerIdentifier: cgmManager.pluginIdentifier, alertResponder: cgmManager) - alertManager?.addAlertSoundVendor(managerIdentifier: cgmManager.managerIdentifier, + alertManager?.addAlertSoundVendor(managerIdentifier: cgmManager.pluginIdentifier, soundVendor: cgmManager) cgmHasValidSensorSession = cgmManager.cgmManagerStatus.hasValidSensorSession - analyticsServicesManager.identifyCGMType(cgmManager.managerIdentifier) + analyticsServicesManager.identifyCGMType(cgmManager.pluginIdentifier) } if let cgmManagerUI = cgmManager as? CGMManagerUI { @@ -723,6 +728,7 @@ private extension DeviceDataManager { pumpManager?.pumpManagerDelegate = self pumpManager?.delegateQueue = queue + reportPluginInitializationComplete() doseStore.device = pumpManager?.status.device pumpManagerHUDProvider = pumpManager?.hudProvider(bluetoothProvider: bluetoothProvider, colorPalette: .default, allowedInsulinTypes: allowedInsulinTypes) @@ -732,14 +738,14 @@ private extension DeviceDataManager { doseStore.pumpRecordsBasalProfileStartEvents = pumpRecordsBasalProfileStartEvents } if let pumpManager = pumpManager { - alertManager?.addAlertResponder(managerIdentifier: pumpManager.managerIdentifier, + alertManager?.addAlertResponder(managerIdentifier: pumpManager.pluginIdentifier, alertResponder: pumpManager) - alertManager?.addAlertSoundVendor(managerIdentifier: pumpManager.managerIdentifier, + alertManager?.addAlertSoundVendor(managerIdentifier: pumpManager.pluginIdentifier, soundVendor: pumpManager) deliveryUncertaintyAlertManager = DeliveryUncertaintyAlertManager(pumpManager: pumpManager, alertPresenter: alertPresenter) - analyticsServicesManager.identifyPumpType(pumpManager.managerIdentifier) + analyticsServicesManager.identifyPumpType(pumpManager.pluginIdentifier) } } @@ -750,6 +756,58 @@ private extension DeviceDataManager { } } +// MARK: - Plugins +extension DeviceDataManager { + func reportPluginInitializationComplete() { + let allActivePlugins = self.allActivePlugins + + for plugin in servicesManager.activeServices { + plugin.initializationComplete(for: allActivePlugins) + } + + for plugin in statefulPluginManager.activeStatefulPlugins { + plugin.initializationComplete(for: allActivePlugins) + } + + for plugin in availableSupports { + plugin.initializationComplete(for: allActivePlugins) + } + + cgmManager?.initializationComplete(for: allActivePlugins) + pumpManager?.initializationComplete(for: allActivePlugins) + } + + var allActivePlugins: [Pluggable] { + var allActivePlugins: [Pluggable] = servicesManager.activeServices + + for plugin in statefulPluginManager.activeStatefulPlugins { + if !allActivePlugins.contains(where: { $0.pluginIdentifier == plugin.pluginIdentifier }) { + allActivePlugins.append(plugin) + } + } + + for plugin in availableSupports { + if !allActivePlugins.contains(where: { $0.pluginIdentifier == plugin.pluginIdentifier }) { + allActivePlugins.append(plugin) + } + } + + if let cgmManager = cgmManager { + if !allActivePlugins.contains(where: { $0.pluginIdentifier == cgmManager.pluginIdentifier }) { + allActivePlugins.append(cgmManager) + } + } + + if let pumpManager = pumpManager { + if !allActivePlugins.contains(where: { $0.pluginIdentifier == pumpManager.pluginIdentifier }) { + allActivePlugins.append(pumpManager) + } + } + + return allActivePlugins + } +} + // MARK: - Client API extension DeviceDataManager { func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void = { _ in }) { @@ -861,7 +919,7 @@ extension DeviceDataManager { extension DeviceDataManager: DeviceManagerDelegate { func deviceManager(_ manager: DeviceManager, logEventForDeviceIdentifier deviceIdentifier: String?, type: DeviceLogEntryType, message: String, completion: ((Error?) -> Void)?) { - deviceLog.log(managerIdentifier: manager.managerIdentifier, deviceIdentifier: deviceIdentifier, type: type, message: message, completion: completion) + deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: deviceIdentifier, type: type, message: message, completion: completion) } var allowDebugFeatures: Bool { @@ -909,7 +967,7 @@ extension DeviceDataManager: CGMManagerDelegate { func cgmManagerWantsDeletion(_ manager: CGMManager) { dispatchPrecondition(condition: .onQueue(queue)) - log.default("CGM manager with identifier '%{public}@' wants deletion", manager.managerIdentifier) + log.default("CGM manager with identifier '%{public}@' wants deletion", manager.pluginIdentifier) DispatchQueue.main.async { if let cgmManagerUI = self.cgmManager as? CGMManagerUI { @@ -962,13 +1020,13 @@ extension DeviceDataManager: CGMManagerDelegate { extension DeviceDataManager: CGMManagerOnboardingDelegate { func cgmManagerOnboarding(didCreateCGMManager cgmManager: CGMManagerUI) { - log.default("CGM manager with identifier '%{public}@' created", cgmManager.managerIdentifier) + log.default("CGM manager with identifier '%{public}@' created", cgmManager.pluginIdentifier) self.cgmManager = cgmManager } func cgmManagerOnboarding(didOnboardCGMManager cgmManager: CGMManagerUI) { precondition(cgmManager.isOnboarded) - log.default("CGM manager with identifier '%{public}@' onboarded", cgmManager.managerIdentifier) + log.default("CGM manager with identifier '%{public}@' onboarded", cgmManager.pluginIdentifier) DispatchQueue.main.async { self.refreshDeviceData() @@ -1101,7 +1159,7 @@ extension DeviceDataManager: PumpManagerDelegate { func pumpManagerWillDeactivate(_ pumpManager: PumpManager) { dispatchPrecondition(condition: .onQueue(queue)) - log.default("Pump manager with identifier '%{public}@' will deactivate", pumpManager.managerIdentifier) + log.default("Pump manager with identifier '%{public}@' will deactivate", pumpManager.pluginIdentifier) DispatchQueue.main.async { self.pumpManager = nil @@ -1170,13 +1228,13 @@ extension DeviceDataManager: PumpManagerDelegate { extension DeviceDataManager: PumpManagerOnboardingDelegate { func pumpManagerOnboarding(didCreatePumpManager pumpManager: PumpManagerUI) { - log.default("Pump manager with identifier '%{public}@' created", pumpManager.managerIdentifier) + log.default("Pump manager with identifier '%{public}@' created", pumpManager.pluginIdentifier) self.pumpManager = pumpManager } func pumpManagerOnboarding(didOnboardPumpManager pumpManager: PumpManagerUI) { precondition(pumpManager.isOnboarded) - log.default("Pump manager with identifier '%{public}@' onboarded", pumpManager.managerIdentifier) + log.default("Pump manager with identifier '%{public}@' onboarded", pumpManager.pluginIdentifier) DispatchQueue.main.async { self.refreshDeviceData() diff --git a/Loop/Managers/LoggingServicesManager.swift b/Loop/Managers/LoggingServicesManager.swift index 25b63ac1f9..287371aa01 100644 --- a/Loop/Managers/LoggingServicesManager.swift +++ b/Loop/Managers/LoggingServicesManager.swift @@ -24,7 +24,7 @@ final class LoggingServicesManager: Logging { } func removeService(_ loggingService: LoggingService) { - loggingServices.removeAll { $0.serviceIdentifier == loggingService.serviceIdentifier } + loggingServices.removeAll { $0.pluginIdentifier == loggingService.pluginIdentifier } } func log (_ message: StaticString, subsystem: String, category: String, type: OSLogType, _ args: [CVarArg]) { diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 43c62d128b..0f441651b5 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -226,6 +226,7 @@ class LoopAppManager: NSObject { onboardingManager = OnboardingManager(pluginManager: pluginManager, bluetoothProvider: bluetoothStateManager, deviceDataManager: deviceDataManager, + statefulPluginManager: deviceDataManager.statefulPluginManager, servicesManager: deviceDataManager.servicesManager, loopDataManager: deviceDataManager.loopManager, supportManager: supportManager, @@ -238,11 +239,8 @@ class LoopAppManager: NSObject { if let analyticsService = support as? AnalyticsService { analyticsServicesManager.addService(analyticsService) } + support.initializationComplete(for: deviceDataManager.allActivePlugins) } - for support in supportManager.availableSupports { - support.initializationComplete(for: deviceDataManager.servicesManager.activeServices) - } - deviceDataManager.onboardingManager = onboardingManager @@ -254,7 +252,7 @@ class LoopAppManager: NSObject { } analyticsServicesManager.identify("Dosing Strategy", value: settingsManager.loopSettings.automaticDosingStrategy.analyticsValue) - let serviceNames = deviceDataManager.servicesManager.activeServices.map { $0.serviceIdentifier } + let serviceNames = deviceDataManager.servicesManager.activeServices.map { $0.pluginIdentifier } analyticsServicesManager.identify("Services", array: serviceNames) if FeatureFlags.scenariosEnabled { diff --git a/Loop/Managers/OnboardingManager.swift b/Loop/Managers/OnboardingManager.swift index b39e0d7d35..b9f6c8c232 100644 --- a/Loop/Managers/OnboardingManager.swift +++ b/Loop/Managers/OnboardingManager.swift @@ -15,6 +15,7 @@ class OnboardingManager { private let pluginManager: PluginManager private let bluetoothProvider: BluetoothProvider private let deviceDataManager: DeviceDataManager + private let statefulPluginManager: StatefulPluginManager private let servicesManager: ServicesManager private let loopDataManager: LoopDataManager private let supportManager: SupportManager @@ -39,10 +40,20 @@ class OnboardingManager { private var onboardingCompletion: (() -> Void)? - init(pluginManager: PluginManager, bluetoothProvider: BluetoothProvider, deviceDataManager: DeviceDataManager, servicesManager: ServicesManager, loopDataManager: LoopDataManager, supportManager: SupportManager, windowProvider: WindowProvider?, userDefaults: UserDefaults = .standard) { + init(pluginManager: PluginManager, + bluetoothProvider: BluetoothProvider, + deviceDataManager: DeviceDataManager, + statefulPluginManager: StatefulPluginManager, + servicesManager: ServicesManager, + loopDataManager: LoopDataManager, + supportManager: SupportManager, + windowProvider: WindowProvider?, + userDefaults: UserDefaults = .standard) + { self.pluginManager = pluginManager self.bluetoothProvider = bluetoothProvider self.deviceDataManager = deviceDataManager + self.statefulPluginManager = statefulPluginManager self.servicesManager = servicesManager self.loopDataManager = loopDataManager self.supportManager = supportManager @@ -122,7 +133,7 @@ class OnboardingManager { let onboarding = onboardingType.createOnboarding() guard !onboarding.isOnboarded else { - completedOnboardingIdentifiers.append(onboarding.onboardingIdentifier) + completedOnboardingIdentifiers.append(onboarding.pluginIdentifier) continue } @@ -155,7 +166,7 @@ class OnboardingManager { dispatchPrecondition(condition: .onQueue(.main)) if let activeOnboarding = self.activeOnboarding, !isSuspended { - completedOnboardingIdentifiers.append(activeOnboarding.onboardingIdentifier) + completedOnboardingIdentifiers.append(activeOnboarding.pluginIdentifier) self.activeOnboarding = nil } continueOnboarding() @@ -238,25 +249,25 @@ class OnboardingManager { extension OnboardingManager: OnboardingDelegate { func onboardingDidUpdateState(_ onboarding: OnboardingUI) { - guard onboarding.onboardingIdentifier == activeOnboarding?.onboardingIdentifier else { return } + guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } userDefaults.onboardingManagerActiveOnboardingRawValue = onboarding.rawValue } func onboarding(_ onboarding: OnboardingUI, hasNewTherapySettings therapySettings: TherapySettings) { - guard onboarding.onboardingIdentifier == activeOnboarding?.onboardingIdentifier else { return } + guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } loopDataManager.therapySettings = therapySettings } func onboarding(_ onboarding: OnboardingUI, hasNewDosingEnabled dosingEnabled: Bool) { - guard onboarding.onboardingIdentifier == activeOnboarding?.onboardingIdentifier else { return } + guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } loopDataManager.mutateSettings { settings in settings.dosingEnabled = dosingEnabled } } func onboardingDidSuspend(_ onboarding: OnboardingUI) { - log.debug("OnboardingUI %@ did suspend", onboarding.onboardingIdentifier) - guard onboarding.onboardingIdentifier == activeOnboarding?.onboardingIdentifier else { return } + log.debug("OnboardingUI %@ did suspend", onboarding.pluginIdentifier) + guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } self.isSuspended = true } } @@ -270,7 +281,7 @@ extension OnboardingManager: CompletionDelegate { return } - self.log.debug("completionNotifyingDidComplete during activeOnboarding", activeOnboarding.onboardingIdentifier) + self.log.debug("completionNotifyingDidComplete during activeOnboarding", activeOnboarding.pluginIdentifier) // The `completionNotifyingDidComplete` callback can be called by an onboarding plugin to signal that the user is done with // the onboarding UI, like when pausing, so the onboarding UI can be dismissed. This doesn't necessarily mean that the @@ -340,7 +351,7 @@ extension OnboardingManager: CGMManagerProvider { guard let cgmManager = deviceDataManager.cgmManager else { return deviceDataManager.setupCGMManager(withIdentifier: identifier, prefersToSkipUserInteraction: prefersToSkipUserInteraction) } - guard cgmManager.managerIdentifier == identifier else { + guard cgmManager.pluginIdentifier == identifier else { return .failure(OnboardingError.invalidState) } @@ -384,7 +395,7 @@ extension OnboardingManager: PumpManagerProvider { guard let pumpManager = deviceDataManager.pumpManager else { return deviceDataManager.setupPumpManager(withIdentifier: identifier, initialSettings: settings, prefersToSkipUserInteraction: prefersToSkipUserInteraction) } - guard pumpManager.managerIdentifier == identifier else { + guard pumpManager.pluginIdentifier == identifier else { return .failure(OnboardingError.invalidState) } @@ -396,15 +407,22 @@ extension OnboardingManager: PumpManagerProvider { } } +// MARK: - StatefulPluggableProvider + +extension OnboardingManager: StatefulPluggableProvider { + func statefulPlugin(withIdentifier identifier: String) -> StatefulPluggable? { + statefulPluginManager.statefulPlugin(withIdentifier: identifier) } +} + // MARK: - ServiceProvider -extension OnboardingManager: ServiceProvider { +extension OnboardingManager: ServiceProvider { var activeServices: [Service] { servicesManager.activeServices } var availableServices: [ServiceDescriptor] { servicesManager.availableServices } func onboardService(withIdentifier identifier: String) -> Swift.Result, Error> { - guard let service = activeServices.first(where: { $0.serviceIdentifier == identifier }) else { + guard let service = activeServices.first(where: { $0.pluginIdentifier == identifier }) else { return servicesManager.setupService(withIdentifier: identifier) } @@ -421,6 +439,7 @@ extension OnboardingManager: ServiceProvider { } // MARK: - TherapySettingsProvider + extension OnboardingManager: TherapySettingsProvider { var onboardingTherapySettings: TherapySettings { return loopDataManager.therapySettings @@ -446,7 +465,7 @@ fileprivate extension OnboardingUI { var rawValue: RawValue { return [ - "onboardingIdentifier": onboardingIdentifier, + "onboardingIdentifier": pluginIdentifier, "state": rawState ] } diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift index 14a3416900..36c460e3c3 100644 --- a/Loop/Managers/RemoteDataServicesManager.swift +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -64,7 +64,7 @@ final class RemoteDataServicesManager { func removeService(_ remoteDataService: RemoteDataService) { lock.withLock { - unlockedRemoteDataServices.removeAll { $0.serviceIdentifier == remoteDataService.serviceIdentifier } + unlockedRemoteDataServices.removeAll { $0.pluginIdentifier == remoteDataService.pluginIdentifier } } clearQueryAnchors(for: remoteDataService) } @@ -80,7 +80,7 @@ final class RemoteDataServicesManager { private func dispatchQueue(for remoteDataService: RemoteDataService, withRemoteDataType remoteDataType: RemoteDataType) -> DispatchQueue { - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: remoteDataType) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: remoteDataType) return dispatchQueue(key) } @@ -228,7 +228,7 @@ extension RemoteDataServicesManager { private func uploadAlertData(to remoteDataService: RemoteDataService) { uploadGroup.enter() - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: .alert) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .alert) dispatchQueue(key).async { let semaphore = DispatchSemaphore(value: 0) @@ -275,7 +275,7 @@ extension RemoteDataServicesManager { private func uploadCarbData(to remoteDataService: RemoteDataService) { uploadGroup.enter() - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: .carb) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .carb) dispatchQueue(key).async { let semaphore = DispatchSemaphore(value: 0) @@ -329,7 +329,7 @@ extension RemoteDataServicesManager { private func uploadDoseData(to remoteDataService: RemoteDataService) { uploadGroup.enter() - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: .dose) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .dose) dispatchQueue(key).async { let semaphore = DispatchSemaphore(value: 0) @@ -383,7 +383,7 @@ extension RemoteDataServicesManager { private func uploadDosingDecisionData(to remoteDataService: RemoteDataService) { uploadGroup.enter() - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: .dosingDecision) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .dosingDecision) dispatchQueue(key).async { let semaphore = DispatchSemaphore(value: 0) @@ -442,7 +442,7 @@ extension RemoteDataServicesManager { uploadGroup.enter() - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: .glucose) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .glucose) dispatchQueue(key).async { let semaphore = DispatchSemaphore(value: 0) @@ -496,7 +496,7 @@ extension RemoteDataServicesManager { private func uploadPumpEventData(to remoteDataService: RemoteDataService) { uploadGroup.enter() - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: .pumpEvent) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .pumpEvent) dispatchQueue(for: remoteDataService, withRemoteDataType: .pumpEvent).async { let semaphore = DispatchSemaphore(value: 0) @@ -550,7 +550,7 @@ extension RemoteDataServicesManager { private func uploadSettingsData(to remoteDataService: RemoteDataService) { uploadGroup.enter() - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: .settings) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .settings) dispatchQueue(for: remoteDataService, withRemoteDataType: .settings).async { let semaphore = DispatchSemaphore(value: 0) @@ -604,7 +604,7 @@ extension RemoteDataServicesManager { private func uploadTemporaryOverrideData(to remoteDataService: RemoteDataService) { uploadGroup.enter() - let key = UploadTaskKey(serviceIdentifier: remoteDataService.serviceIdentifier, remoteDataType: .overrides) + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .overrides) dispatchQueue(for: remoteDataService, withRemoteDataType: .overrides).async { let semaphore = DispatchSemaphore(value: 0) @@ -648,7 +648,7 @@ extension RemoteDataServicesManager { func serviceForPushNotification(_ notification: [String: AnyObject]) throws -> RemoteDataService { let defaultServiceIdentifier = "NightscoutService" let serviceIdentifier = notification["serviceIdentifier"] as? String ?? defaultServiceIdentifier - guard let service = remoteDataServices.first(where: {$0.serviceIdentifier == serviceIdentifier}) else { + guard let service = remoteDataServices.first(where: {$0.pluginIdentifier == serviceIdentifier}) else { throw RemoteDataServicesManagerCommandError.unsupportedServiceIdentifier(serviceIdentifier) } return service @@ -674,7 +674,7 @@ protocol RemoteDataServicesManagerDelegate: AnyObject { fileprivate extension UserDefaults { private func queryAnchorKey(for remoteDataService: RemoteDataService, withRemoteDataType remoteDataType: RemoteDataType) -> String { - return "com.loopkit.Loop.RemoteDataServicesManager.\(remoteDataService.serviceIdentifier).\(remoteDataType.rawValue)QueryAnchor" + return "com.loopkit.Loop.RemoteDataServicesManager.\(remoteDataService.pluginIdentifier).\(remoteDataType.rawValue)QueryAnchor" } func getQueryAnchor(for remoteDataService: RemoteDataService, withRemoteDataType remoteDataType: RemoteDataType) -> T? where T: RawRepresentable, T.RawValue == [String: Any] { diff --git a/Loop/Managers/Service.swift b/Loop/Managers/Service.swift index 3966109931..9f4b2f0eee 100644 --- a/Loop/Managers/Service.swift +++ b/Loop/Managers/Service.swift @@ -13,11 +13,11 @@ import MockKit let staticServices: [Service.Type] = [MockService.self] let staticServicesByIdentifier: [String: Service.Type] = staticServices.reduce(into: [:]) { (map, Type) in - map[Type.serviceIdentifier] = Type + map[Type.pluginIdentifier] = Type } let availableStaticServices = staticServices.map { (Type) -> ServiceDescriptor in - return ServiceDescriptor(identifier: Type.serviceIdentifier, localizedTitle: Type.localizedTitle) + return ServiceDescriptor(identifier: Type.pluginIdentifier, localizedTitle: Type.localizedTitle) } func ServiceFromRawValue(_ rawValue: [String: Any]) -> Service? { @@ -30,16 +30,3 @@ func ServiceFromRawValue(_ rawValue: [String: Any]) -> Service? { return ServiceType.init(rawState: rawState) } - -extension Service { - - typealias RawValue = [String: Any] - - var rawValue: RawValue { - return [ - "serviceIdentifier": serviceIdentifier, - "state": rawState - ] - } - -} diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index 2593560706..1b0ab15b9a 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -124,6 +124,7 @@ class ServicesManager { public func addActiveService(_ service: Service) { servicesLock.withLock { service.serviceDelegate = self + service.stateDelegate = self services.append(service) @@ -153,9 +154,10 @@ class ServicesManager { analyticsServicesManager.removeService(analyticsService) } - services.removeAll { $0.serviceIdentifier == service.serviceIdentifier } + services.removeAll { $0.pluginIdentifier == service.pluginIdentifier } service.serviceDelegate = nil + service.stateDelegate = nil saveState() } @@ -171,6 +173,7 @@ class ServicesManager { rawServices.forEach { rawValue in if let service = serviceFromRawValue(rawValue) { service.serviceDelegate = self + service.stateDelegate = self services.append(service) @@ -238,6 +241,19 @@ public protocol ServicesManagerDelegate: AnyObject { func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws } +// MARK: - StatefulPluggableDelegate +extension ServicesManager: StatefulPluggableDelegate { + func pluginDidUpdateState(_ plugin: StatefulPluggable) { + saveState() + } + + func pluginWantsDeletion(_ plugin: StatefulPluggable) { + guard let service = plugin as? Service else { return } + log.default("Service with identifier '%{public}@' deleted", service.pluginIdentifier) + removeActiveService(service) + } +} + // MARK: - ServiceDelegate extension ServicesManager: ServiceDelegate { @@ -256,15 +272,6 @@ extension ServicesManager: ServiceDelegate { return semanticVersion } - - func serviceDidUpdateState(_ service: Service) { - saveState() - } - - func serviceWantsDeletion(_ service: Service) { - log.default("Service with identifier '%{public}@' deleted", service.serviceIdentifier) - removeActiveService(service) - } func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws { @@ -380,13 +387,13 @@ extension ServicesManager: AlertIssuer { extension ServicesManager: ServiceOnboardingDelegate { func serviceOnboarding(didCreateService service: Service) { - log.default("Service with identifier '%{public}@' created", service.serviceIdentifier) + log.default("Service with identifier '%{public}@' created", service.pluginIdentifier) addActiveService(service) } func serviceOnboarding(didOnboardService service: Service) { precondition(service.isOnboarded) - log.default("Service with identifier '%{public}@' onboarded", service.serviceIdentifier) + log.default("Service with identifier '%{public}@' onboarded", service.pluginIdentifier) } } diff --git a/Loop/Managers/StatefulPluggable.swift b/Loop/Managers/StatefulPluggable.swift new file mode 100644 index 0000000000..ab1be4754d --- /dev/null +++ b/Loop/Managers/StatefulPluggable.swift @@ -0,0 +1,20 @@ +// +// StatefulPluggable.swift +// Loop +// +// Created by Nathaniel Hamming on 2023-09-13. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import LoopKit + +extension StatefulPluggable { + typealias RawValue = [String: Any] + + var rawValue: RawValue { + return [ + "statefulPluginIdentifier": pluginIdentifier, + "state": rawState + ] + } +} diff --git a/Loop/Managers/StatefulPluginManager.swift b/Loop/Managers/StatefulPluginManager.swift new file mode 100644 index 0000000000..22fc035b0c --- /dev/null +++ b/Loop/Managers/StatefulPluginManager.swift @@ -0,0 +1,125 @@ +// +// StatefulPluginManager.swift +// Loop +// +// Created by Nathaniel Hamming on 2023-09-06. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import LoopCore +import Combine + +class StatefulPluginManager: StatefulPluggableProvider { + + private let pluginManager: PluginManager + + private let servicesManager: ServicesManager + + private var statefulPlugins = [StatefulPluggable]() + + private let statefulPluginLock = UnfairLock() + + @PersistedProperty(key: "StatefulPlugins") + var rawStatefulPlugins: [StatefulPluggable.RawStateValue]? + + init(pluginManager: PluginManager, + servicesManager: ServicesManager) + { + self.pluginManager = pluginManager + self.servicesManager = servicesManager + restoreState() + } + + public var availableStatefulPluginIdentifiers: [String] { + return pluginManager.availableStatefulPluginIdentifiers + } + + func statefulPlugin(withIdentifier identifier: String) -> StatefulPluggable? { + for plugin in statefulPlugins { + if plugin.pluginIdentifier == identifier { + return plugin + } + } + + return setupStatefulPlugin(withIdentifier: identifier) + } + + func statefulPluginType(withIdentifier identifier: String) -> StatefulPluggable.Type? { + pluginManager.getStatefulPluginTypeByIdentifier(identifier) + } + + func setupStatefulPlugin(withIdentifier identifier: String) -> StatefulPluggable? { + guard let statefulPluinType = pluginManager.getStatefulPluginTypeByIdentifier(identifier) else { return nil } + + // init without raw value + let statefulPlugin = statefulPluinType.init(rawState: [:]) + statefulPlugin?.initializationComplete(for: servicesManager.activeServices) + addActiveStatefulPlugin(statefulPlugin) + + return statefulPlugin + } + + private func statefulPluginTypeFromRawValue(_ rawValue: StatefulPluggable.RawStateValue) -> StatefulPluggable.Type? { + guard let identifier = rawValue["statefulPluginIdentifier"] as? String else { + return nil + } + + return statefulPluginType(withIdentifier: identifier) + } + + private func statefulPluginFromRawValue(_ rawValue: StatefulPluggable.RawStateValue) -> StatefulPluggable? { + guard let statefulPluginType = statefulPluginTypeFromRawValue(rawValue), + let rawState = rawValue["state"] as? StatefulPluggable.RawStateValue + else { + return nil + } + + return statefulPluginType.init(rawState: rawState) + } + + public var activeStatefulPlugins: [StatefulPluggable] { + return statefulPluginLock.withLock { statefulPlugins } + } + + public func addActiveStatefulPlugin(_ statefulPlugin: StatefulPluggable?) { + guard let statefulPlugin = statefulPlugin else { return } + statefulPluginLock.withLock { + statefulPlugin.stateDelegate = self + statefulPlugins.append(statefulPlugin) + saveState() + } + } + + public func removeActiveStatefulPlugin(_ statefulPlugin: StatefulPluggable) { + statefulPluginLock.withLock { + statefulPlugins.removeAll { $0.pluginIdentifier == statefulPlugin.pluginIdentifier } + saveState() + } + } + + private func saveState() { + rawStatefulPlugins = statefulPlugins.compactMap { $0.rawValue } + } + + private func restoreState() { + let rawStatefulPlugins = rawStatefulPlugins ?? [] + rawStatefulPlugins.forEach { rawValue in + if let statefulPlugin = statefulPluginFromRawValue(rawValue) { + statefulPlugin.initializationComplete(for: servicesManager.activeServices) + statefulPlugins.append(statefulPlugin) + } + } + } +} + +extension StatefulPluginManager: StatefulPluggableDelegate { + func pluginDidUpdateState(_ plugin: StatefulPluggable) { + saveState() + } + + func pluginWantsDeletion(_ plugin: LoopKit.StatefulPluggable) { + removeActiveStatefulPlugin(plugin) + } +} diff --git a/Loop/Managers/SupportManager.swift b/Loop/Managers/SupportManager.swift index dae07c7e25..58cddddf74 100644 --- a/Loop/Managers/SupportManager.swift +++ b/Loop/Managers/SupportManager.swift @@ -54,7 +54,7 @@ public final class SupportManager { self.pluginManager = pluginManager self.staticSupportTypes = [] staticSupportTypesByIdentifier = self.staticSupportTypes.reduce(into: [:]) { (map, type) in - map[type.supportIdentifier] = type + map[type.pluginIdentifier] = type } restoreState() @@ -75,7 +75,7 @@ public final class SupportManager { for bundle in remainingSupportBundles { do { if let support = try bundle.loadAndInstantiateSupport() { - log.debug("Loaded support plugin: %{public}@", support.identifier) + log.debug("Loaded support plugin: %{public}@", support.pluginIdentifier) addSupport(support) } } catch { @@ -111,8 +111,8 @@ public final class SupportManager { extension SupportManager { func addSupport(_ support: SupportUI) { supports.mutate { - if $0[support.identifier] == nil { - $0[support.identifier] = support + if $0[support.pluginIdentifier] == nil { + $0[support.pluginIdentifier] = support support.delegate = self } } @@ -124,7 +124,7 @@ extension SupportManager { func removeSupport(_ support: SupportUI) { supports.mutate { - $0[support.identifier] = nil + $0[support.pluginIdentifier] = nil support.delegate = self } } @@ -156,7 +156,7 @@ extension SupportManager { supports.value.values.forEach { support in group.addTask { - return (await support.checkVersion(bundleIdentifier: Bundle.main.bundleIdentifier!, currentVersion: Bundle.main.shortVersionString), support.identifier) + return (await support.checkVersion(bundleIdentifier: Bundle.main.bundleIdentifier!, currentVersion: Bundle.main.shortVersionString), support.pluginIdentifier) } } @@ -331,7 +331,7 @@ fileprivate extension UserDefaults { extension SupportUI { var rawValue: RawStateValue { return [ - "supportIdentifier": Self.supportIdentifier, + "supportIdentifier": Self.pluginIdentifier, "state": rawState ] } diff --git a/Loop/Managers/TestingScenariosManager.swift b/Loop/Managers/TestingScenariosManager.swift index eabf1b060a..b71e357433 100644 --- a/Loop/Managers/TestingScenariosManager.swift +++ b/Loop/Managers/TestingScenariosManager.swift @@ -199,7 +199,7 @@ extension TestingScenariosManagerRequirements { if instance.hasCGMData { if let cgmManager = deviceManager.cgmManager as? TestingCGMManager { if instance.shouldReloadManager?.cgm == true { - testingCGMManager = reloadCGMManager(withIdentifier: cgmManager.managerIdentifier) + testingCGMManager = reloadCGMManager(withIdentifier: cgmManager.pluginIdentifier) } else { testingCGMManager = cgmManager } @@ -212,7 +212,7 @@ extension TestingScenariosManagerRequirements { if instance.hasPumpData { if let pumpManager = deviceManager.pumpManager as? TestingPumpManager { if instance.shouldReloadManager?.pump == true { - testingPumpManager = reloadPumpManager(withIdentifier: pumpManager.managerIdentifier) + testingPumpManager = reloadPumpManager(withIdentifier: pumpManager.pluginIdentifier) } else { testingPumpManager = pumpManager } @@ -243,9 +243,9 @@ extension TestingScenariosManagerRequirements { } instance.deviceActions.forEach { [testingCGMManager, testingPumpManager] action in - if testingCGMManager?.managerIdentifier == action.managerIdentifier { + if testingCGMManager?.pluginIdentifier == action.managerIdentifier { testingCGMManager?.trigger(action: action) - } else if testingPumpManager?.managerIdentifier == action.managerIdentifier { + } else if testingPumpManager?.pluginIdentifier == action.managerIdentifier { testingPumpManager?.trigger(action: action) } } diff --git a/Loop/Plugins/PluginManager.swift b/Loop/Plugins/PluginManager.swift index bec19e0602..a254d26872 100644 --- a/Loop/Plugins/PluginManager.swift +++ b/Loop/Plugins/PluginManager.swift @@ -145,6 +145,37 @@ class PluginManager { return ServiceDescriptor(identifier: identifier, localizedTitle: title) }) } + + func getStatefulPluginTypeByIdentifier(_ identifier: String) -> StatefulPluggable.Type? { + for bundle in pluginBundles { + if let name = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.statefulPluginIdentifier.rawValue) as? String, name == identifier { + do { + try bundle.loadAndReturnError() + + if let principalClass = bundle.principalClass as? NSObject.Type { + + if let plugin = principalClass.init() as? StatefulPlugin { + return plugin.pluginType + } else { + fatalError("PrincipalClass does not conform to StatefulPlugin") + } + + } else { + fatalError("PrincipalClass not found") + } + } catch let error { + log.error("Error loading plugin: %{public}@", String(describing: error)) + } + } + } + return nil + } + + var availableStatefulPluginIdentifiers: [String] { + return pluginBundles.compactMap({ (bundle) -> String? in + return bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.statefulPluginIdentifier.rawValue) as? String + }) + } func getOnboardingTypeByIdentifier(_ identifier: String) -> OnboardingUI.Type? { for bundle in pluginBundles { @@ -201,18 +232,18 @@ class PluginManager { } return nil } - } extension Bundle { var isPumpManagerPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.pumpManagerIdentifier.rawValue) as? String != nil } var isCGMManagerPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.cgmManagerIdentifier.rawValue) as? String != nil } + var isStatefulPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.statefulPluginIdentifier.rawValue) as? String != nil } var isServicePlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.serviceIdentifier.rawValue) as? String != nil } var isOnboardingPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.onboardingIdentifier.rawValue) as? String != nil } var isSupportPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.supportIdentifier.rawValue) as? String != nil } - var isLoopPlugin: Bool { isPumpManagerPlugin || isCGMManagerPlugin || isServicePlugin || isOnboardingPlugin || isSupportPlugin } + var isLoopPlugin: Bool { isPumpManagerPlugin || isCGMManagerPlugin || isStatefulPlugin || isServicePlugin || isOnboardingPlugin || isSupportPlugin } var isLoopExtension: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.extensionIdentifier.rawValue) as? String != nil } diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 3e5312dafb..8906a75986 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -2255,7 +2255,7 @@ extension StatusTableViewController: ServicesViewModelDelegate { } func gotoService(withIdentifier identifier: String) { - guard let serviceUI = deviceManager.servicesManager.activeServices.first(where: { $0.serviceIdentifier == identifier }) as? ServiceUI else { + guard let serviceUI = deviceManager.servicesManager.activeServices.first(where: { $0.pluginIdentifier == identifier }) as? ServiceUI else { return } showServiceSettings(serviceUI) diff --git a/Loop/View Models/ServicesViewModel.swift b/Loop/View Models/ServicesViewModel.swift index d59e1e6603..19fb2a7d57 100644 --- a/Loop/View Models/ServicesViewModel.swift +++ b/Loop/View Models/ServicesViewModel.swift @@ -24,7 +24,7 @@ public class ServicesViewModel: ObservableObject { var inactiveServices: () -> [ServiceDescriptor] { return { return self.availableServices().filter { availableService in - !self.activeServices().contains { $0.serviceIdentifier == availableService.identifier } + !self.activeServices().contains { $0.pluginIdentifier == availableService.identifier } } } } @@ -42,7 +42,7 @@ public class ServicesViewModel: ObservableObject { } func didTapService(_ index: Int) { - delegate?.gotoService(withIdentifier: activeServices()[index].serviceIdentifier) + delegate?.gotoService(withIdentifier: activeServices()[index].pluginIdentifier) } func didTapAddService(_ availableService: ServiceDescriptor) { @@ -54,23 +54,25 @@ public class ServicesViewModel: ObservableObject { extension ServicesViewModel { fileprivate class FakeService1: Service { static var localizedTitle: String = "Service 1" - static var serviceIdentifier: String = "FakeService1" + static var pluginIdentifier: String = "FakeService1" + var stateDelegate: StatefulPluggableDelegate? var serviceDelegate: ServiceDelegate? var rawState: RawStateValue = [:] required init() {} required init?(rawState: RawStateValue) {} let isOnboarded = true - var available: ServiceDescriptor { ServiceDescriptor(identifier: serviceIdentifier, localizedTitle: localizedTitle) } + var available: ServiceDescriptor { ServiceDescriptor(identifier: pluginIdentifier, localizedTitle: localizedTitle) } } fileprivate class FakeService2: Service { static var localizedTitle: String = "Service 2" - static var serviceIdentifier: String = "FakeService2" + static var pluginIdentifier: String = "FakeService2" + var stateDelegate: StatefulPluggableDelegate? var serviceDelegate: ServiceDelegate? var rawState: RawStateValue = [:] required init() {} required init?(rawState: RawStateValue) {} let isOnboarded = true - var available: ServiceDescriptor { ServiceDescriptor(identifier: serviceIdentifier, localizedTitle: localizedTitle) } + var available: ServiceDescriptor { ServiceDescriptor(identifier: pluginIdentifier, localizedTitle: localizedTitle) } } static var preview: ServicesViewModel { diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index b8e9c1daf7..795b544b2b 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -308,7 +308,7 @@ extension SettingsView { private var pluginMenuItems: [PluginMenuItem] { self.viewModel.availableSupports.flatMap { plugin in plugin.configurationMenuItems().enumerated().map { index, item in - PluginMenuItem(section: item.section, view: item.view, pluginIdentifier: plugin.identifier, offset: index) + PluginMenuItem(section: item.section, view: item.view, pluginIdentifier: plugin.pluginIdentifier, offset: index) } } } diff --git a/LoopTests/Managers/DoseEnactorTests.swift b/LoopTests/Managers/DoseEnactorTests.swift index 72359793e6..bf722ec874 100644 --- a/LoopTests/Managers/DoseEnactorTests.swift +++ b/LoopTests/Managers/DoseEnactorTests.swift @@ -121,7 +121,7 @@ class MockPumpManager: PumpManager { .minutes(units / deliveryUnitsPerMinute) } - var managerIdentifier: String = "MockPumpManager" + static var pluginIdentifier: String = "MockPumpManager" var localizedTitle: String = "MockPumpManager" diff --git a/LoopTests/Managers/SupportManagerTests.swift b/LoopTests/Managers/SupportManagerTests.swift index ac0d42b512..48fa42e4d8 100644 --- a/LoopTests/Managers/SupportManagerTests.swift +++ b/LoopTests/Managers/SupportManagerTests.swift @@ -34,7 +34,7 @@ class SupportManagerTests: XCTestCase { weak var delegate: SupportUIDelegate? } class MockSupport: Mixin, SupportUI { - static var supportIdentifier: String { "SupportManagerTestsMockSupport" } + static var pluginIdentifier: String { "SupportManagerTestsMockSupport" } override init() { super.init() } required init?(rawState: RawStateValue) { super.init() } var rawState: RawStateValue = [:] @@ -42,12 +42,11 @@ class SupportManagerTests: XCTestCase { func getScenarios(from scenarioURLs: [URL]) -> [LoopScenario] { [] } func loopWillReset() {} func loopDidReset() {} - func initializationComplete(for services: [LoopKit.Service]) {} func configurationMenuItems() -> [LoopKitUI.CustomMenuItem] { return [] } } class AnotherMockSupport: Mixin, SupportUI { - static var supportIdentifier: String { "SupportManagerTestsAnotherMockSupport" } + static var pluginIdentifier: String { "SupportManagerTestsAnotherMockSupport" } override init() { super.init() } required init?(rawState: RawStateValue) { super.init() } var rawState: RawStateValue = [:] @@ -55,7 +54,6 @@ class SupportManagerTests: XCTestCase { func getScenarios(from scenarioURLs: [URL]) -> [LoopScenario] { [] } func loopWillReset() {} func loopDidReset() {} - func initializationComplete(for services: [LoopKit.Service]) {} func configurationMenuItems() -> [LoopKitUI.CustomMenuItem] { return [] } } From 49b329e3a4199fff07df7aa21da95182ac663c1b Mon Sep 17 00:00:00 2001 From: Nathaniel Hamming Date: Thu, 21 Sep 2023 02:53:24 -0300 Subject: [PATCH 5/5] [COASTAL-1291] corrected rawValue key for service restore (#598) --- Loop/Managers/Service.swift | 2 +- Loop/Managers/ServicesManager.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/Service.swift b/Loop/Managers/Service.swift index 9f4b2f0eee..1541208712 100644 --- a/Loop/Managers/Service.swift +++ b/Loop/Managers/Service.swift @@ -21,7 +21,7 @@ let availableStaticServices = staticServices.map { (Type) -> ServiceDescriptor i } func ServiceFromRawValue(_ rawValue: [String: Any]) -> Service? { - guard let serviceIdentifier = rawValue["serviceIdentifier"] as? String, + guard let serviceIdentifier = rawValue["statefulPluginIdentifier"] as? String, let rawState = rawValue["state"] as? Service.RawStateValue, let ServiceType = staticServicesByIdentifier[serviceIdentifier] else { diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index 1b0ab15b9a..7e62e95333 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -100,7 +100,7 @@ class ServicesManager { } private func serviceTypeFromRawValue(_ rawValue: Service.RawStateValue) -> Service.Type? { - guard let identifier = rawValue["serviceIdentifier"] as? String else { + guard let identifier = rawValue["statefulPluginIdentifier"] as? String else { return nil }