| // Copyright 2020 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import Foundation |
| import SwiftUI |
| import WidgetKit |
| |
| struct ConfigureQuickActionsWidgetEntry: TimelineEntry { |
| let date: Date |
| let useLens: Bool |
| let useColorLensAndVoiceIcons: Bool |
| let isPreview: Bool |
| let avatar: Image? |
| let gaiaID: String? |
| let deleted: Bool |
| } |
| |
| struct ConfigureQuickActionsWidgetEntryProvider: TimelineProvider { |
| func placeholder(in context: Context) -> ConfigureQuickActionsWidgetEntry { |
| ConfigureQuickActionsWidgetEntry( |
| date: Date(), useLens: false, useColorLensAndVoiceIcons: false, isPreview: true, avatar: nil, |
| gaiaID: nil, deleted: false) |
| } |
| |
| func getSnapshot( |
| in context: Context, |
| completion: @escaping (ConfigureQuickActionsWidgetEntry) -> Void |
| ) { |
| let entry = ConfigureQuickActionsWidgetEntry( |
| date: Date(), |
| useLens: shouldUseLens(), |
| useColorLensAndVoiceIcons: shouldUseColorLensAndVoiceIcons(), |
| isPreview: context.isPreview, |
| avatar: nil, |
| gaiaID: nil, |
| deleted: false |
| ) |
| completion(entry) |
| } |
| |
| func getTimeline( |
| in context: Context, |
| completion: @escaping (Timeline<Entry>) -> Void |
| ) { |
| let entry = ConfigureQuickActionsWidgetEntry( |
| date: Date(), |
| useLens: shouldUseLens(), |
| useColorLensAndVoiceIcons: shouldUseColorLensAndVoiceIcons(), |
| isPreview: context.isPreview, |
| avatar: nil, |
| gaiaID: nil, |
| deleted: false |
| ) |
| let entries: [ConfigureQuickActionsWidgetEntry] = [entry] |
| let timeline: Timeline = Timeline(entries: entries, policy: .never) |
| completion(timeline) |
| } |
| } |
| |
| struct QuickActionsWidget: Widget { |
| // Changing 'kind' or deleting this widget will cause all installed instances of this widget to |
| // stop updating and show the placeholder state. |
| let kind: String = "QuickActionsWidget" |
| |
| var body: some WidgetConfiguration { |
| StaticConfiguration( |
| kind: kind, |
| provider: ConfigureQuickActionsWidgetEntryProvider() |
| ) { entry in |
| QuickActionsWidgetEntryView(entry: entry) |
| } |
| .configurationDisplayName( |
| Text("IDS_IOS_WIDGET_KIT_EXTENSION_QUICK_ACTIONS_DISPLAY_NAME") |
| ) |
| .description(Text("IDS_IOS_WIDGET_KIT_EXTENSION_QUICK_ACTIONS_DESCRIPTION")) |
| .supportedFamilies([.systemMedium]) |
| .crDisfavoredLocations() |
| .crContentMarginsDisabled() |
| .crContainerBackgroundRemovable(false) |
| } |
| } |
| |
| #if IOS_ENABLE_WIDGETS_FOR_MIM |
| @available(iOS 17, *) |
| struct QuickActionsWidgetConfigurable: Widget { |
| // Changing 'kind' or deleting this widget will cause all installed instances of this widget to |
| // stop updating and show the placeholder state. |
| let kind: String = "QuickActionsWidget" |
| |
| var body: some WidgetConfiguration { |
| AppIntentConfiguration( |
| kind: kind, |
| intent: SelectAccountIntent.self, |
| provider: ConfigurableQuickActionsWidgetEntryProvider() |
| ) { entry in |
| QuickActionsWidgetEntryView(entry: entry) |
| } |
| .configurationDisplayName( |
| Text("IDS_IOS_WIDGET_KIT_EXTENSION_QUICK_ACTIONS_DISPLAY_NAME") |
| ) |
| .description(Text("IDS_IOS_WIDGET_KIT_EXTENSION_QUICK_ACTIONS_DESCRIPTION")) |
| .supportedFamilies([.systemMedium]) |
| .crDisfavoredLocations() |
| .crContentMarginsDisabled() |
| .crContainerBackgroundRemovable(false) |
| } |
| } |
| |
| // Advises WidgetKit when to update a widget’s display. |
| @available(iOS 17, *) |
| struct ConfigurableQuickActionsWidgetEntryProvider: AppIntentTimelineProvider { |
| |
| func placeholder(in context: Context) -> ConfigureQuickActionsWidgetEntry { |
| ConfigureQuickActionsWidgetEntry( |
| date: Date(), useLens: false, useColorLensAndVoiceIcons: false, isPreview: true, |
| avatar: nil, gaiaID: nil, deleted: false) |
| } |
| |
| func snapshot(for configuration: SelectAccountIntent, in context: Context) async |
| -> ConfigureQuickActionsWidgetEntry |
| { |
| let avatar: Image? = configuration.avatar() |
| let gaiaID: String? = configuration.gaia() |
| let deleted: Bool = configuration.deleted() |
| |
| let entry = ConfigureQuickActionsWidgetEntry( |
| date: Date(), |
| useLens: shouldUseLens(), |
| useColorLensAndVoiceIcons: shouldUseColorLensAndVoiceIcons(), |
| isPreview: context.isPreview, |
| avatar: avatar, |
| gaiaID: gaiaID, |
| deleted: deleted |
| ) |
| return entry |
| } |
| |
| func timeline(for configuration: SelectAccountIntent, in context: Context) async -> Timeline< |
| ConfigureQuickActionsWidgetEntry |
| > { |
| let avatar: Image? = configuration.avatar() |
| let gaiaID: String? = configuration.gaia() |
| let deleted: Bool = configuration.deleted() |
| |
| let entry = ConfigureQuickActionsWidgetEntry( |
| date: Date(), |
| useLens: shouldUseLens(), |
| useColorLensAndVoiceIcons: shouldUseColorLensAndVoiceIcons(), |
| isPreview: context.isPreview, |
| avatar: avatar, |
| gaiaID: gaiaID, |
| deleted: deleted |
| ) |
| let entries: [ConfigureQuickActionsWidgetEntry] = [entry] |
| let timeline: Timeline = Timeline(entries: entries, policy: .never) |
| return timeline |
| } |
| } |
| #endif |
| |
| func shouldUseLens() -> Bool { |
| let sharedDefaults: UserDefaults = AppGroupHelper.groupUserDefaults() |
| let useLens: Bool = |
| sharedDefaults.bool( |
| forKey: WidgetConstants.QuickActionsWidget.isGoogleDefaultSearchEngineKey) |
| && sharedDefaults.bool( |
| forKey: WidgetConstants.QuickActionsWidget.enableLensInWidgetKey) |
| return useLens |
| } |
| |
| func shouldUseColorLensAndVoiceIcons() -> Bool { |
| // On iOS 15, color icons are not supported in widget, always return false |
| // as no icon would be displayed. |
| // On iOS 16, color icons are displayed in monochrome, so still present |
| // the monochrome icon as it may be better adapted. |
| if #available(iOS 17, *) { |
| return shouldUseLens() |
| } |
| return false |
| } |
| |
| struct QuickActionsWidgetEntryView: View { |
| var entry: ConfigureQuickActionsWidgetEntry |
| @Environment(\.colorScheme) var colorScheme: ColorScheme |
| @Environment(\.redactionReasons) var redactionReasons |
| private let searchAreaHeight: CGFloat = 92 |
| private let separatorHeight: CGFloat = 32 |
| private let incognitoA11yLabel: LocalizedStringKey = |
| "IDS_IOS_WIDGET_KIT_EXTENSION_QUICK_ACTIONS_INCOGNITO_A11Y_LABEL" |
| private let voiceSearchA11yLabel: LocalizedStringKey = |
| "IDS_IOS_WIDGET_KIT_EXTENSION_QUICK_ACTIONS_VOICE_SEARCH_A11Y_LABEL" |
| private let lensA11yLabel: LocalizedStringKey = |
| "IDS_IOS_WIDGET_KIT_EXTENSION_QUICK_ACTIONS_LENS_A11Y_LABEL" |
| private let qrA11yLabel: LocalizedStringKey = |
| "IDS_IOS_WIDGET_KIT_EXTENSION_QUICK_ACTIONS_QR_SCAN_A11Y_LABEL" |
| |
| func symbolWithName(symbolName: String, system: Bool) -> some View { |
| let image = system ? Image(systemName: symbolName) : Image(symbolName) |
| return image.foregroundColor(Color("widget_actions_icon_color")).font( |
| .system(size: 20, weight: .medium) |
| ).imageScale(.medium) |
| } |
| |
| var body: some View { |
| // The account to display was deleted (entry.deleted can only be true if |
| // IOS_ENABLE_WIDGETS_FOR_MIM is enabled). |
| if entry.deleted && !entry.isPreview { |
| MediumWidgetDeletedAccountView() |
| } else { |
| VStack(spacing: 0) { |
| ZStack { |
| VStack { |
| Spacer() |
| Link( |
| destination: destinationURL( |
| url: WidgetConstants.QuickActionsWidget.searchUrl, gaia: entry.gaiaID) |
| ) { |
| ZStack { |
| RoundedRectangle(cornerRadius: 26) |
| .frame(height: 52) |
| .foregroundColor(Color("widget_search_bar_color")) |
| HStack(spacing: 12) { |
| Image("widget_chrome_logo") |
| .clipShape(Circle()) |
| // Without .clipShape(Circle()), in the redacted/placeholder |
| // state the Widget shows an rectangle placeholder instead of |
| // a circular one. |
| .padding(.leading, 8) |
| .unredacted() |
| Text("IDS_IOS_WIDGET_KIT_EXTENSION_QUICK_ACTIONS_TITLE") |
| .font(.subheadline) |
| .foregroundColor(Color("widget_text_color")) |
| Spacer() |
| #if IOS_ENABLE_WIDGETS_FOR_MIM |
| Avatar(entry: entry) |
| #endif |
| } |
| } |
| .frame(minWidth: 0, maxWidth: .infinity) |
| .padding([.leading, .trailing], 11) |
| } |
| .accessibility( |
| label: |
| Text( |
| "IDS_IOS_WIDGET_KIT_EXTENSION_QUICK_ACTIONS_SEARCH_A11Y_LABEL" |
| ) |
| ) |
| Spacer() |
| } |
| .frame(height: searchAreaHeight) |
| } |
| ZStack { |
| Rectangle() |
| .foregroundColor(Color("widget_actions_row_background_color")) |
| .frame(minWidth: 0, maxWidth: .infinity) |
| HStack { |
| // Show interactive buttons if the widget is fully loaded, and show |
| // the custom placeholder otherwise. |
| if redactionReasons.isEmpty { |
| Link( |
| destination: destinationURL( |
| url: WidgetConstants.QuickActionsWidget.incognitoUrl, gaia: entry.gaiaID) |
| ) { |
| symbolWithName(symbolName: "widget_incognito_icon", system: false) |
| .frame(minWidth: 0, maxWidth: .infinity) |
| } |
| .accessibility(label: Text(incognitoA11yLabel)) |
| Separator(height: separatorHeight) |
| Link( |
| destination: destinationURL( |
| url: WidgetConstants.QuickActionsWidget.voiceSearchUrl, gaia: entry.gaiaID) |
| ) { |
| symbolWithName(symbolName: "widget_voice_icon", system: false) |
| .symbolRenderingMode( |
| (colorScheme == .light && entry.useColorLensAndVoiceIcons) |
| ? .multicolor : .monochrome |
| ) |
| .frame(minWidth: 0, maxWidth: .infinity) |
| } |
| .accessibility(label: Text(voiceSearchA11yLabel)) |
| Separator(height: separatorHeight) |
| if entry.useLens { |
| Link( |
| destination: destinationURL( |
| url: WidgetConstants.QuickActionsWidget.lensUrl, gaia: entry.gaiaID) |
| ) { |
| symbolWithName(symbolName: "widget_lens_icon", system: false) |
| .symbolRenderingMode( |
| (colorScheme == .light && entry.useColorLensAndVoiceIcons) |
| ? .multicolor : .monochrome |
| ) |
| .frame(minWidth: 0, maxWidth: .infinity) |
| } |
| .accessibility(label: Text(lensA11yLabel)) |
| } else { |
| Link( |
| destination: destinationURL( |
| url: WidgetConstants.QuickActionsWidget.qrCodeUrl, gaia: entry.gaiaID) |
| ) { |
| symbolWithName(symbolName: "qrcode", system: true) |
| .frame(minWidth: 0, maxWidth: .infinity) |
| } |
| .accessibility(label: Text(qrA11yLabel)) |
| } |
| } else { |
| ButtonPlaceholder() |
| Separator(height: separatorHeight) |
| ButtonPlaceholder() |
| Separator(height: separatorHeight) |
| ButtonPlaceholder() |
| } |
| } |
| .frame(minWidth: 0, maxWidth: .infinity) |
| .padding([.leading, .trailing], 11) |
| } |
| } |
| .crContainerBackground(Color("widget_background_color").unredacted()) |
| } |
| } |
| } |
| |
| struct Separator: View { |
| let height: CGFloat |
| var body: some View { |
| RoundedRectangle(cornerRadius: 1) |
| .foregroundColor(Color("widget_separator_color")) |
| .frame(width: 2, height: height) |
| } |
| } |
| |
| struct ButtonPlaceholder: View { |
| private let widthOfPlaceholder: CGFloat = 28 |
| var body: some View { |
| Group { |
| RoundedRectangle(cornerRadius: 4, style: .continuous) |
| .frame(width: widthOfPlaceholder, height: widthOfPlaceholder) |
| .foregroundColor(Color("widget_text_color")) |
| .opacity(0.3) |
| }.frame(minWidth: 0, maxWidth: .infinity) |
| } |
| } |
| |
| struct Avatar: View { |
| var entry: ConfigureQuickActionsWidgetEntry |
| var body: some View { |
| if entry.isPreview { |
| Circle() |
| .foregroundColor(Color("widget_text_color")) |
| .opacity(0.2) |
| .frame(width: 35, height: 35) |
| .padding(.trailing, 8) |
| } else if let avatar = entry.avatar { |
| avatar |
| .resizable() |
| .clipShape(Circle()) |
| .unredacted() |
| .scaledToFill() |
| .frame(width: 35, height: 35) |
| .padding(.trailing, 8) |
| } |
| } |
| } |