chromium/ios/chrome/widget_kit_extension/shortcuts_widget.swift

// 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?
}

// 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)
  }

  // Return an Entry with the most visited sites
  func loadMostVisitedSitesEntry(isPreview: Bool) -> Entry {

    // A constant of an empty entry
    let emptyEntry = Entry(
      date: Date(),
      mostVisitedSites: [:],
      isPreview: isPreview,
      isExpired: false,
      expirationDate: nil
    )
    // A constant of an expired entry
    let expiredEntry = Entry(
      date: Date(),
      mostVisitedSites: [:],
      isPreview: isPreview,
      isExpired: true,
      expirationDate: nil
    )
    // Returns an empty entry if the Shortcuts Widget is in the Widgets Gallery
    if isPreview {
      return emptyEntry
    }

    guard let sharedDefaults: UserDefaults = AppGroupHelper.groupUserDefaults(),
      let lastModificationDate = sharedDefaults.object(forKey: "SuggestedItemsLastModificationDate")
        as? Date
    else {
      return emptyEntry
    }

    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
    }

    guard let data = sharedDefaults.object(forKey: "SuggestedItems") as? Data,
      let unarchiver = try? NSKeyedUnarchiver(forReadingFrom: data)
    else { return emptyEntry }

    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
    )
  }

  // 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)
  }
}

// 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 = URLQueryItem(name: "url", value: url.absoluteString)
    var urlcomps = URLComponents(
      url: WidgetConstants.ShortcutsWidget.open, resolvingAgainstBaseURL: false)!
    urlcomps.queryItems = [query]
    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: WidgetConstants.ShortcutsWidget.searchUrl) {
      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()
        }
      }
      .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 {
    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)
  }
}