mirror of
https://github.com/home-assistant/iOS.git
synced 2026-04-12 15:26:45 -05:00
## 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
408 lines
14 KiB
Swift
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()
|
|
})
|
|
}
|
|
}
|