Files
iOS/Sources/App/Settings/AppleWatch/HomeCustomization/WatchConfigurationView.swift
Joel Hawksley b816f4c376 Fix watch folder bugs (#4404)
## Summary

This PR aims to fix a couple of bugs I noticed while testing the new
watch folders feature in the latest Test Flight build:

1) Changing the folder name, icon, and icon after it was created did not
work.
2) When adding a folder, the folder name should be a placeholder, not a
pre-filled field.
3) When switching between a folder and the root view, the items should
not resize after rendering (see the last item here):


https://github.com/user-attachments/assets/262cb313-f307-455f-ba21-7a189a3f7589

Note: I could not reproduce the resizing bug in the simulator, and
haven't been able to get Xcode to let me build to my devices locally.
Would you be up for trying to reproduce it/verify my fix on your end?

## Link to pull request in Documentation repository

N/A
2026-03-04 09:04:01 +01:00

408 lines
14 KiB
Swift

import SFSafeSymbols
import Shared
import StoreKit
import SwiftUI
enum WatchSupportedDomains {
static var all: [Domain] = [
.script,
.scene,
]
}
struct WatchConfigurationView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: WatchConfigurationViewModel
@State private var isLoaded = false
@State private var showResetConfirmation = false
@State private var showAddFolderSheet = false
@State private var newFolderName: String = L10n.Watch.Configuration.Folder.defaultName
private let needsNavigationController: Bool
init(needsNavigationController: Bool = false, viewModel: WatchConfigurationViewModel? = nil) {
self.needsNavigationController = needsNavigationController
self._viewModel = .init(wrappedValue: viewModel ?? WatchConfigurationViewModel())
}
var body: some View {
if needsNavigationController {
if #available(iOS 16.0, *) {
NavigationStack {
content
}
} else {
NavigationView {
content
}
}
} else {
content
}
}
private var content: some View {
List {
watchPreview
.listRowBackground(Color.clear)
.onAppear {
// Prevent trigger when popping nav controller
guard !isLoaded else { return }
viewModel.loadWatchConfig()
isLoaded = true
}
itemsSection
assistSection
resetView
}
.preferredColorScheme(.dark)
.navigationTitle("Apple Watch")
.navigationBarTitleDisplayMode(.inline)
.toolbar(content: {
ToolbarItem(placement: .topBarTrailing) {
Button(action: {
let success = viewModel.save()
if success {
// When iOS 15 support is dropped we can start using `@Environment(\.requestReview)
// private var requestReview`
SKStoreReviewController.requestReview()
dismiss()
}
}, label: {
Text(verbatim: L10n.Watch.Configuration.Save.title)
})
}
})
.sheet(isPresented: $viewModel.showAddItem, content: {
MagicItemAddView(context: .watch) { itemToAdd in
guard let itemToAdd else { return }
viewModel.addItem(itemToAdd)
}
.preferredColorScheme(.dark)
})
.alert(viewModel.errorMessage ?? L10n.errorLabel, isPresented: $viewModel.showError) {
Button(action: {}, label: {
Text(verbatim: L10n.okLabel)
})
}
.sheet(isPresented: $showAddFolderSheet) {
addFolderSheet
}
}
private var resetView: some View {
Button(L10n.Watch.Debug.DeleteDb.Reset.title, role: .destructive) {
showResetConfirmation = true
}
.confirmationDialog(
L10n.Watch.Debug.DeleteDb.Alert.title,
isPresented: $showResetConfirmation,
titleVisibility: .visible
) {
Button(L10n.yesLabel, role: .destructive) {
viewModel.deleteConfiguration { success in
if success {
dismiss()
}
}
}
Button(L10n.noLabel, role: .cancel) {}
}
}
private var itemsSection: some View {
Section(L10n.Watch.Configuration.Items.title) {
ForEach(viewModel.watchConfig.items, id: \.serverUniqueId) { item in
makeListItem(item: item)
}
.onMove { indices, newOffset in
viewModel.moveItem(from: indices, to: newOffset)
}
.onDelete { indexSet in
viewModel.deleteItem(at: indexSet)
}
Button {
viewModel.showAddItem = true
} label: {
Label(L10n.Watch.Configuration.AddItem.title, systemSymbol: .plus)
}
Button {
newFolderName = ""
showAddFolderSheet = true
} label: {
Label(L10n.Watch.Configuration.AddFolder.title, systemSymbol: .folder)
}
}
}
@ViewBuilder
private var addFolderSheet: some View {
if #available(iOS 16.0, *) {
NavigationStack {
addFolderForm
}
.presentationDetents([.medium])
.preferredColorScheme(.dark)
} else {
NavigationView {
addFolderForm
}
.navigationViewStyle(.stack)
.preferredColorScheme(.dark)
}
}
private var addFolderForm: some View {
Form {
Section(L10n.Watch.Configuration.FolderName.title) {
TextField(L10n.Watch.Configuration.Folder.defaultName, text: $newFolderName)
.textInputAutocapitalization(.words)
}
}
.navigationTitle(L10n.Watch.Configuration.NewFolder.title)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(action: { showAddFolderSheet = false }) {
Text(L10n.cancelLabel)
}
}
ToolbarItem(placement: .confirmationAction) {
Button(action: {
let name = newFolderName.trimmingCharacters(in: .whitespacesAndNewlines)
viewModel.addFolder(
named: name.isEmpty ? L10n.Watch.Configuration.Folder.defaultName : name
)
showAddFolderSheet = false
}) {
Text(L10n.Watch.Configuration.AddFolder.title)
}
}
}
}
private var assistSection: some View {
Section("Assist") {
Toggle(isOn: $viewModel.watchConfig.assist.showAssist, label: {
Text(verbatim: L10n.Watch.Configuration.ShowAssist.title)
})
if viewModel.watchConfig.assist.showAssist {
HStack {
Text(L10n.Watch.Labels.SelectedPipeline.title)
.frame(maxWidth: .infinity, alignment: .leading)
AssistPipelinePicker(
selectedServerId: $viewModel.watchConfig.assist.serverId,
selectedPipelineId: $viewModel.watchConfig.assist.pipelineId
)
}
}
}
}
private var watchPreview: some View {
ZStack {
watchItemsList
.offset(x: -10)
Image(.watchFrame)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 260)
.foregroundStyle(.clear, Color(hue: 0, saturation: 0, brightness: 0.2))
}
.frame(maxWidth: .infinity, alignment: .center)
}
private var watchItemsList: some View {
ZStack(alignment: .top) {
List {
VStack {}.padding(.top, 40)
Group {
ForEach(viewModel.watchConfig.items, id: \.serverUniqueId) { item in
makeWatchItem(item: item)
}
if viewModel.watchConfig.items.isEmpty {
noItemsWatchView
}
}
.listRowSeparator(.hidden)
.listRowSpacing(DesignSystem.Spaces.half)
}
.animation(.default, value: viewModel.watchConfig.items.map(\.contentHash))
.listStyle(.plain)
.frame(width: 200, height: 265)
.offset(x: 5, y: 10)
watchStatusBar
.offset(y: 10)
}
.clipShape(RoundedRectangle(cornerRadius: 62))
}
private func makeListItem(item: MagicItem) -> some View {
let itemInfo = viewModel.magicItemInfo(for: item) ?? .init(
id: item.id,
name: item.id,
iconName: "",
customization: nil
)
return makeListItemRow(item: item, info: itemInfo)
}
@ViewBuilder
private func makeListItemRow(item: MagicItem, info: MagicItem.Info) -> some View {
if item.type == .action {
itemRow(item: item, info: info)
} else if item.type == .folder {
NavigationLink {
FolderDetailView(
folderId: item.id,
viewModel: viewModel
)
.environment(\.colorScheme, .dark)
} label: {
itemRow(item: item, info: info)
}
} else {
NavigationLink {
MagicItemCustomizationView(mode: .edit, context: .watch, item: item) { updatedMagicItem in
viewModel.updateItem(updatedMagicItem)
}
.environment(\.colorScheme, .dark)
} label: {
itemRow(item: item, info: info)
}
}
}
private func itemRow(item: MagicItem, info: MagicItem.Info) -> some View {
HStack {
Image(uiImage: image(for: item, itemInfo: info, watchPreview: false, color: .haPrimary))
Text(item.name(info: info))
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemSymbol: .line3Horizontal)
.foregroundStyle(.gray)
}
}
private func makeWatchItem(item: MagicItem) -> some View {
let itemInfo = viewModel.magicItemInfo(for: item) ?? .init(
id: item.id,
name: item.id,
iconName: "",
customization: nil
)
let iconColor = iconColorForItem(item: item, itemInfo: itemInfo)
return HStack(spacing: DesignSystem.Spaces.one) {
VStack {
Image(uiImage: image(for: item, itemInfo: itemInfo, watchPreview: true))
.foregroundColor(iconColor)
.padding(DesignSystem.Spaces.one)
}
.background(iconColor.opacity(0.3))
.clipShape(Circle())
Text(item.name(info: itemInfo))
.font(.system(size: 16))
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(textColorForWatchItem(itemInfo: itemInfo))
if item.type == .folder {
Image(systemSymbol: .chevronRight)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.trailing, DesignSystem.Spaces.half)
}
}
.padding(DesignSystem.Spaces.one)
.frame(width: 190, height: 55)
.background(backgroundForWatchItem(itemInfo: itemInfo))
.clipShape(RoundedRectangle(cornerRadius: 14))
.padding(.vertical, -DesignSystem.Spaces.one)
.listRowSeparator(.hidden)
}
private func backgroundForWatchItem(itemInfo: MagicItem.Info) -> Color {
if let backgroundColor = itemInfo.customization?.backgroundColor {
Color(uiColor: .init(hex: backgroundColor))
} else {
Color.gray.opacity(0.3)
}
}
private func textColorForWatchItem(itemInfo: MagicItem.Info) -> Color {
if let textColor = itemInfo.customization?.textColor {
Color(uiColor: .init(hex: textColor))
} else {
Color.white
}
}
private func iconColorForItem(item: MagicItem, itemInfo: MagicItem.Info) -> Color {
if let iconColor = item.customization?.iconColor ?? itemInfo.customization?.iconColor {
Color(uiColor: .init(hex: iconColor))
} else {
Color.haPrimary
}
}
private func image(
for item: MagicItem,
itemInfo: MagicItem.Info,
watchPreview: Bool,
color: UIColor? = nil
) -> UIImage {
let icon: MaterialDesignIcons = item.icon(info: itemInfo)
let resolvedColor: UIColor = if let color {
color
} else if let iconColor = item.customization?.iconColor ?? itemInfo.customization?.iconColor {
.init(hex: iconColor)
} else {
.haPrimary
}
return icon.image(
ofSize: .init(width: watchPreview ? 24 : 18, height: watchPreview ? 24 : 18),
color: resolvedColor
)
}
private var watchStatusBar: some View {
ZStack(alignment: .trailing) {
Text("9:41")
.font(.system(size: 14).bold())
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top)
if viewModel.watchConfig.assist.showAssist {
Image(uiImage: MaterialDesignIcons.messageProcessingOutlineIcon.image(
ofSize: .init(width: 18, height: 18),
color: .haPrimary
))
.padding(DesignSystem.Spaces.one)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 25.0))
.offset(x: -22)
.padding(.top)
}
}
.animation(.bouncy, value: viewModel.watchConfig.assist.showAssist)
.frame(width: 210, height: 50)
.background(LinearGradient(colors: [.black, .clear], startPoint: .top, endPoint: .bottom))
}
private var noItemsWatchView: some View {
Text(verbatim: L10n.Watch.Settings.NoItems.Phone.title)
.frame(maxWidth: .infinity, alignment: .center)
.font(.footnote)
.padding(DesignSystem.Spaces.one)
.background(.gray.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: DesignSystem.CornerRadius.one))
}
}
#Preview {
VStack {
Text("Abc")
.sheet(isPresented: .constant(true), content: {
WatchConfigurationView()
})
}
}