blob: 097d823d25a7107953df9a3646b33024f8fca212 [file] [log] [blame]
// Copyright 2023 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
enum Constants {
//A constant variable to count the number of seconds in a month
static let secondsInFourWeeks: TimeInterval = 4 * 7 * 24 * 60 * 60
//A constant variable to count the number of seconds in a month
static let secondsInFiveMinutes: TimeInterval = 5 * 60
}
// Specifies the date of the current widget and indicates the widget's content.
struct ConfigureShortcutsWidgetEntry: TimelineEntry {
// Date and time to update the widget’s shortcuts.
let date: Date
// A dictionary containing the most visited URLs
// and their NTPTiles from the UserDefaults.
let mostVisitedSites: [NSURL: NTPTile]
// A Boolean value that indicates when the widget appears in the widget gallery.
let isPreview: Bool
// A Boolean value that indicates when the user didn't opened Chrome for the more than one month.
let isExpired: Bool
// Expiration date of the widget if it hasn't expired.
let expirationDate: Date?
// Account avatar (to be used when multiprofile flag is enabled).
let avatar: Image?
let gaiaID: String?
let deleted: Bool
}
// Advises WidgetKit when to update a widget’s display.
struct ConfigureShortcutsWidgetEntryProvider: TimelineProvider {
// A type that specifies the entry of the configured timeline entry of the widget.
typealias Entry = ConfigureShortcutsWidgetEntry
// Provides a timeline entry representing a placeholder version of the widget.
func placeholder(in context: TimelineProviderContext) -> Entry {
return Entry(
date: Date(), mostVisitedSites: [:], isPreview: true, isExpired: false, expirationDate: nil,
avatar: nil, gaiaID: nil, deleted: false)
}
// Provides a timeline entry that represents the current time and state of a widget.
func getSnapshot(
in context: TimelineProviderContext,
completion: @escaping (Entry) -> Void
) {
let entry = loadMostVisitedSitesEntry(isPreview: context.isPreview)
completion(entry)
}
// Provides an array of timeline entries for the current time.
func getTimeline(
in context: TimelineProviderContext,
completion: @escaping (Timeline<Entry>) -> Void
) {
let entry = loadMostVisitedSitesEntry(isPreview: context.isPreview)
let entries = [entry]
let timeline = Timeline(
entries: entries, policy: entry.expirationDate.map { .after($0) } ?? .never)
completion(timeline)
}
}
// Provides the configuration and content of a widget to display on the Home screen.
struct ShortcutsWidget: 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 = "ShortcutsWidget"
let deviceModel = UIDevice.current.model
var body: some WidgetConfiguration {
StaticConfiguration(
kind: kind,
provider: ConfigureShortcutsWidgetEntryProvider()
) { entry in
ShortcutsWidgetEntryView(entry: entry)
}
.configurationDisplayName(
Text("IDS_IOS_WIDGET_KIT_EXTENSION_SHORTCUTS_DISPLAY_NAME")
)
.description(
deviceModel == "iPhone"
? Text("IDS_IOS_WIDGET_KIT_EXTENSION_SHORTCUTS_DESCRIPTION_IPHONE")
: Text("IDS_IOS_WIDGET_KIT_EXTENSION_SHORTCUTS_DESCRIPTION_IPAD")
)
.supportedFamilies([.systemMedium])
.crDisfavoredLocations()
.crContentMarginsDisabled()
.crContainerBackgroundRemovable(false)
}
}
#if IOS_ENABLE_WIDGETS_FOR_MIM
@available(iOS 17, *)
// Provides the configuration and content of a widget to display on the Home screen.
struct ShortcutsWidgetConfigurable: 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 = "ShortcutsWidget"
let deviceModel = UIDevice.current.model
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: SelectAccountIntent.self,
provider: ConfigurableShortcutsWidgetEntryProvider()
) { entry in
ShortcutsWidgetEntryView(entry: entry)
}
.configurationDisplayName(
Text("IDS_IOS_WIDGET_KIT_EXTENSION_SHORTCUTS_DISPLAY_NAME")
)
.description(
deviceModel == "iPhone"
? Text("IDS_IOS_WIDGET_KIT_EXTENSION_SHORTCUTS_DESCRIPTION_IPHONE")
: Text("IDS_IOS_WIDGET_KIT_EXTENSION_SHORTCUTS_DESCRIPTION_IPAD")
)
.supportedFamilies([.systemMedium])
.crDisfavoredLocations()
.crContentMarginsDisabled()
.crContainerBackgroundRemovable(false)
}
}
// Advises WidgetKit when to update a widget’s display.
@available(iOS 17, *)
struct ConfigurableShortcutsWidgetEntryProvider: AppIntentTimelineProvider {
// A type that specifies the entry of the configured timeline entry of the widget.
typealias Entry = ConfigureShortcutsWidgetEntry
// Provides a timeline entry representing a placeholder version of the widget.
func placeholder(in context: TimelineProviderContext) -> Entry {
return Entry(
date: Date(), mostVisitedSites: [:], isPreview: true, isExpired: false, expirationDate: nil,
avatar: nil, gaiaID: nil, deleted: false)
}
// Provides a timeline entry that represents the current time and state of a widget.
func snapshot(for configuration: SelectAccountIntent, in context: Context) async -> Entry {
let avatar: Image? = configuration.avatar()
let gaiaID: String? = configuration.gaia()
let deleted: Bool = configuration.deleted()
let entry = loadMostVisitedSitesEntry(
isPreview: context.isPreview, avatar: avatar, gaia: gaiaID, deleted: deleted)
return entry
}
// Provides an array of timeline entries for the current time.
func timeline(for configuration: SelectAccountIntent, in context: Context) async -> Timeline<
Entry
> {
let avatar: Image? = configuration.avatar()
let gaiaID: String? = configuration.gaia()
let deleted: Bool = configuration.deleted()
let entry = loadMostVisitedSitesEntry(
isPreview: context.isPreview, avatar: avatar, gaia: gaiaID, deleted: deleted)
let entries = [entry]
let timeline = Timeline(
entries: entries, policy: entry.expirationDate.map { .after($0) } ?? .never)
return timeline
}
}
#endif
// Return ConfigureShortcutsWidgetEntry with the most visited sites
func loadMostVisitedSitesEntry(
isPreview: Bool, avatar: Image? = nil, gaia: String? = nil, deleted: Bool = false
)
-> ConfigureShortcutsWidgetEntry
{
// A type that specifies the entry of the configured timeline entry of the widget.
typealias Entry = ConfigureShortcutsWidgetEntry
// A constant of an empty entry.
let emptyEntry = Entry(
date: Date(),
mostVisitedSites: [:],
isPreview: isPreview,
isExpired: false,
expirationDate: nil,
avatar: avatar,
gaiaID: gaia,
deleted: deleted
)
// A constant of an expired entry.
let expiredEntry = Entry(
date: Date(),
mostVisitedSites: [:],
isPreview: isPreview,
isExpired: true,
expirationDate: nil,
avatar: avatar,
gaiaID: gaia,
deleted: deleted
)
// Returns an empty entry if the Shortcuts Widget is in the Widgets Gallery.
if isPreview {
return emptyEntry
}
#if IOS_ENABLE_WIDGETS_FOR_MIM
guard let sharedDefaults: UserDefaults = AppGroupHelper.groupUserDefaults(),
let lastModificationDates = sharedDefaults.object(
forKey: "SuggestedItemsLastModificationDateForMIM")
as? [String: Date]
else { return emptyEntry }
var date: Date?
for (key, value) in lastModificationDates {
if gaia == key {
date = value
}
}
guard let lastModificationDate = date
else { return emptyEntry }
#else
guard let sharedDefaults: UserDefaults = AppGroupHelper.groupUserDefaults(),
let lastModificationDate = sharedDefaults.object(forKey: "SuggestedItemsLastModificationDate")
as? Date
else { return emptyEntry }
#endif
let extensionsFlags =
sharedDefaults.object(forKey: "Extension.FieldTrial") as? [String: Any] ?? [:]
let fiveMinutestoRefreshTestFlag =
extensionsFlags["WidgetKitRefreshFiveMinutes"] as? [String: Any] ?? [:]
// A constant to know the status of WidgetKitRefreshFiveMinutes Test Flag.
let fiveMinutestoRefreshTestValue =
fiveMinutestoRefreshTestFlag["FieldTrialValue"] as? Bool ?? false
// A constant to get the number of seconds of the last modification date of the installed widget.
let numberOfSecondsSinceLastModification = Date.now.timeIntervalSince(lastModificationDate)
// A constant to get the number of seconds to refresh the widget after it has been closed.
let numberOfSecondsFromLastModificationToExpiration =
fiveMinutestoRefreshTestValue ? Constants.secondsInFiveMinutes : Constants.secondsInFourWeeks
let expirationDate = lastModificationDate.advanced(
by: numberOfSecondsFromLastModificationToExpiration)
// Return an Expired entry in the case of passing the limit of refreshing seconds.
if numberOfSecondsFromLastModificationToExpiration < numberOfSecondsSinceLastModification {
return expiredEntry
}
#if IOS_ENABLE_WIDGETS_FOR_MIM
guard let data = sharedDefaults.object(forKey: "SuggestedItemsForMIM") as? [String: Data]
else { return emptyEntry }
var unarchiverForAccount: NSKeyedUnarchiver?
for (key, value) in data {
if gaia == key {
unarchiverForAccount = try? NSKeyedUnarchiver(forReadingFrom: value)
}
}
guard let unarchiver = unarchiverForAccount
else { return emptyEntry }
#else
guard let data = sharedDefaults.object(forKey: "SuggestedItems") as? Data,
let unarchiver = try? NSKeyedUnarchiver(forReadingFrom: data)
else { return emptyEntry }
#endif
unarchiver.requiresSecureCoding = false
guard
let mostVisitedSites = unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey)
as? [NSURL: NTPTile]
else {
return emptyEntry
}
return Entry(
date: Date(),
mostVisitedSites: mostVisitedSites,
isPreview: isPreview,
isExpired: false,
expirationDate: expirationDate,
avatar: avatar,
gaiaID: gaia,
deleted: deleted
)
}
// Presents the shortcuts widget with SwiftUI views.
struct ShortcutsWidgetEntryView: View {
let entry: ConfigureShortcutsWidgetEntry
enum Dimensions {
static let searchAreaHeight: CGFloat = 92
static let separatorHeight: CGFloat = 32
static let stackFramePadding: CGFloat = 11
static let iconsPadding: CGFloat = 19
static let placeholdersPadding: CGFloat = 1.0
}
enum Strings {
static let widgetDisplayName = String(
localized: "IDS_IOS_WIDGET_KIT_EXTENSION_SHORTCUTS_DISPLAY_NAME")
static let searchA11yLabel = String(
localized:
"IDS_IOS_WIDGET_KIT_EXTENSION_SHORTCUTS_SEARCH_A11Y_LABEL")
static let openShorcutLabelTemplate = String(
localized: "IDS_IOS_WIDGET_KIT_EXTENSION_SHORTCUTS_OPEN_SHORTCUT_LABEL")
static let noShortcutsAvailableTitle = String(
localized: "IDS_IOS_WIDGET_KIT_EXTENSION_SHORTCUTS_NO_SHORTCUTS_AVAILABLE_LABEL")
static let expiredShortcutsTitle = String(
localized: "IDS_IOS_WIDGET_KIT_EXTENSION_SHORTCUTS_EXPIRED_OPEN_CHROME")
}
enum Colors {
static let widgetBackgroundColor = Color("widget_background_color")
static let widgetMostVisitedSitesRow = Color("widget_actions_row_background_color")
static let widgetTextColor = Color("widget_text_color")
static let widgetSearchBarColor = Color("widget_search_bar_color")
}
// Create a chromewidgetkit:// url to open the given URL.
private func convertURL(url: URL) -> URL {
let query_url = URLQueryItem(name: "url", value: url.absoluteString)
var urlcomps = URLComponents(
url: WidgetConstants.ShortcutsWidget.open,
resolvingAgainstBaseURL: false)!
if entry.gaiaID == nil {
urlcomps.queryItems = [query_url]
} else {
// Add the gaia_id parameter only if available.
let query_gaia = URLQueryItem(name: "gaia_id", value: entry.gaiaID)
urlcomps.queryItems = [query_url, query_gaia]
}
return urlcomps.url!
}
// Shows the search bar of the shortcuts widget.
@ViewBuilder
private var searchBar: some View {
let cornerRadius: CGFloat = 26
let height: CGFloat = 52
let spacing: CGFloat = 12
let padding: CGFloat = 8
Link(
destination: destinationURL(
url: WidgetConstants.ShortcutsWidget.searchUrl, gaia: entry.gaiaID)
) {
ZStack {
RoundedRectangle(cornerRadius: cornerRadius)
.frame(height: height)
.foregroundColor(Colors.widgetSearchBarColor)
HStack(spacing: spacing) {
Image("widget_chrome_logo")
.clipShape(Circle())
.padding(.leading, padding)
.unredacted()
Text(Strings.searchA11yLabel)
.font(.subheadline)
.foregroundColor(Colors.widgetTextColor)
Spacer()
#if IOS_ENABLE_WIDGETS_FOR_MIM
AvatarForShortcuts(entry: entry)
#endif
}
}
.frame(minWidth: 0, maxWidth: .infinity)
.padding([.leading, .trailing], Dimensions.stackFramePadding)
}
.accessibilityLabel(Strings.searchA11yLabel)
}
// Shows the widget with 4 shortcuts placeholder in the gallery view to respect user's privacy.
public var websitesPlaceholder: some View {
HStack(spacing: 3) {
WebSitePlaceholder()
SeparatorVertical()
WebSitePlaceholder()
SeparatorVertical()
WebSitePlaceholder()
SeparatorVertical()
WebSitePlaceholder()
}
.frame(minWidth: 0, maxWidth: .infinity)
}
// Shows the "No shortcuts available" text when the user deletes
// all their most visited websites from Chrome App.
private var zeroVisitedSitesView: some View {
WebsiteLabel(websiteTitle: Strings.noShortcutsAvailableTitle).padding(.leading, 10)
.accessibilityLabel(Strings.noShortcutsAvailableTitle)
}
// Shows the "Open Chrome to see your most visited sites" text
// if Chrome has not been opened for a long time.
private var expiredMostVisitedSitesView: some View {
WebsiteLabel(websiteTitle: Strings.expiredShortcutsTitle).padding(.leading, 10)
.accessibilityLabel(Strings.expiredShortcutsTitle)
}
// Shows the shortcut's icon with website's title on the left.
@ViewBuilder
private func oneVisitedSitesView(ntpTile: NTPTile) -> some View {
Link(destination: convertURL(url: ntpTile.url)) {
HStack {
WebsiteLogo(ntpTile: ntpTile).padding(.leading, 12)
WebsiteLabel(
websiteTitle: Strings.openShorcutLabelTemplate.replacingOccurrences(
of: "WEBSITE_PLACEHOLDER", with: ntpTile.title ?? "")
)
.padding(.leading, 8)
}
}
.accessibilityLabel(ntpTile.title)
}
// Shows the shortcuts containing the most visited websites.
@ViewBuilder
private func multipleVisitedSitesView(ntpTiles: [NTPTile]) -> some View {
// The maximum number of sites that can be displayed.
let maxNumberOfShortcuts = 4
let numberOfShortcuts = min(ntpTiles.count, maxNumberOfShortcuts)
ForEach(0..<numberOfShortcuts, id: \.self) { index in
HStack(spacing: 0.5) {
Link(destination: convertURL(url: ntpTiles[index].url)) {
WebsiteLogo(ntpTile: ntpTiles[index])
}
.accessibilityLabel(ntpTiles[index].title)
}
.frame(minWidth: 0, maxWidth: .infinity)
.padding([.leading, .trailing], Dimensions.iconsPadding)
if index < numberOfShortcuts - 1 {
SeparatorVertical()
}
}
}
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) {
searchBar.frame(height: Dimensions.searchAreaHeight)
ZStack {
Rectangle()
.foregroundColor(Colors.widgetMostVisitedSitesRow)
.frame(minWidth: 0, maxWidth: .infinity)
.accessibilityLabel(Strings.widgetDisplayName)
HStack {
let ntpTiles = Array(entry.mostVisitedSites.values).sorted()
if entry.isPreview {
websitesPlaceholder
} else if entry.isExpired {
expiredMostVisitedSitesView
} else {
switch ntpTiles.count {
case 0:
zeroVisitedSitesView
case 1:
oneVisitedSitesView(ntpTile: ntpTiles[0])
default:
multipleVisitedSitesView(ntpTiles: ntpTiles)
}
}
Spacer()
}
.frame(minWidth: 0, maxWidth: .infinity)
}
.frame(maxHeight: .infinity)
}
.crContainerBackground(
Colors.widgetBackgroundColor.unredacted()
)
}
}
}
// Adds the comparable conformance to NTPTile
// to sort the NTPTiles.
extension NTPTile: Comparable {
public static func < (lhs: NTPTile, rhs: NTPTile) -> Bool {
return lhs.position < rhs.position
}
}
// Vertical `|` separator view between two shortcuts in a row of the Shortcuts widget.
struct SeparatorVertical: View {
enum Dimensions {
static let height: CGFloat = 32
static let width: CGFloat = 2
static let cornerRadius: CGFloat = 1
}
enum Colors {
static let widgetSeparatorColor = Color("widget_separator_color")
}
var body: some View {
RoundedRectangle(cornerRadius: Dimensions.cornerRadius)
.foregroundColor(Colors.widgetSeparatorColor)
.frame(width: Dimensions.width, height: Dimensions.height)
}
}
// Generates the placeholder of the shortcut.
struct WebSitePlaceholder: View {
enum Dimensions {
static let placeholderSize: CGFloat = 28
}
enum Colors {
static let placeholderBackgroundColor = Color("widget_text_color")
}
var body: some View {
Circle()
.frame(width: Dimensions.placeholderSize, height: Dimensions.placeholderSize)
.foregroundColor(Colors.placeholderBackgroundColor)
.opacity(0.2)
.frame(minWidth: 0, maxWidth: .infinity)
}
}
// Generates the logo of the shortcut.
struct WebsiteLogo: View {
enum Dimensions {
static let placeholderSize: CGFloat = 28
static let cornerRadius: CGFloat = 4
static let fontSize: CGFloat = 15
}
enum Colors {
static let shortcutBackgroundColor = Color("widget_background_color")
static let shortcutTextColor = Color("widget_text_color")
}
let ntpTile: NTPTile
var backgroundColor: Color {
if let backgroundColor = ntpTile.fallbackBackgroundColor {
return Color(backgroundColor)
} else {
return Colors.shortcutBackgroundColor
}
}
var fallbackMonogram: String {
return ntpTile.fallbackMonogram ?? ""
}
var fallbackTextColor: Color {
if let fallbackTextColor = ntpTile.fallbackTextColor {
return Color(fallbackTextColor)
} else {
return Colors.shortcutTextColor
}
}
var faviconImage: Image? {
let faviconFilePath =
AppGroupHelper.widgetsFaviconsFolder().appendingPathComponent(
ntpTile.faviconFileName)
guard let uiImage = UIImage(contentsOfFile: faviconFilePath.path) else {
return nil
}
return Image(uiImage: uiImage)
}
@ViewBuilder
private func backgroundWithLogo(faviconImage: Image) -> some View {
ZStack {
faviconImage.resizable()
.frame(width: Dimensions.placeholderSize, height: Dimensions.placeholderSize)
.cornerRadius(Dimensions.cornerRadius)
}
}
var monogramIcon: some View {
ZStack {
RoundedRectangle(cornerRadius: Dimensions.cornerRadius, style: .continuous)
.frame(width: Dimensions.placeholderSize, height: Dimensions.placeholderSize)
.foregroundColor(backgroundColor)
monogramText
}
}
var monogramText: some View {
Text(verbatim: fallbackMonogram)
.font(.system(size: Dimensions.fontSize))
.foregroundColor(fallbackTextColor)
}
var body: some View {
if let faviconImage = faviconImage {
backgroundWithLogo(faviconImage: faviconImage).cornerRadius(Dimensions.cornerRadius)
} else {
monogramIcon.cornerRadius(Dimensions.cornerRadius)
}
}
}
// Generates the title of the shortcut.
struct WebsiteLabel: View {
let websiteTitle: String
let fontSize: CGFloat = 13
var body: some View {
Text(verbatim: self.websiteTitle)
.font(.system(size: fontSize))
.opacity(0.59)
}
}
struct AvatarForShortcuts: View {
var entry: ConfigureShortcutsWidgetEntry
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)
}
}
}