diff --git a/Harmonie.xcodeproj/project.pbxproj b/Harmonie.xcodeproj/project.pbxproj index 83ac8e5..cbd6677 100644 --- a/Harmonie.xcodeproj/project.pbxproj +++ b/Harmonie.xcodeproj/project.pbxproj @@ -470,7 +470,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.6.0; + MARKETING_VERSION = 0.7.0; PRODUCT_BUNDLE_IDENTIFIER = jp.mknn.harmonie; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -505,7 +505,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.6.0; + MARKETING_VERSION = 0.7.0; PRODUCT_BUNDLE_IDENTIFIER = jp.mknn.harmonie; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/harmonie/Components/BittenCircleMask.swift b/harmonie/Components/BittenCircleMask.swift new file mode 100644 index 0000000..c8a5e8e --- /dev/null +++ b/harmonie/Components/BittenCircleMask.swift @@ -0,0 +1,42 @@ +// +// BittenCircleMask.swift +// Harmonie +// +// Created by xili on 2024/09/18. +// + +import SwiftUI + +struct BittenCircleMask: Shape { + private let biteSize: CGSize + private let offsetRatio: CGFloat + + init(biteSize: CGSize, offsetRatio: CGFloat = 0.65) { + self.biteSize = biteSize + self.offsetRatio = offsetRatio + } + + func path(in rect: CGRect) -> Path { + var path = rectanglePath(rect) + path.addPath(circlePath(rect)) + return path + } + + private func rectanglePath(_ rect: CGRect) -> Path { + Rectangle().path(in: rect) + } + + private func circlePath(_ rect: CGRect) -> Path { + Circle() + .path(in: circleRect(rect, size: biteSize)) + } + + private func circleRect(_ rect: CGRect, size: CGSize) -> CGRect { + CGRect(origin: .zero, size: size) + .offsetBy(dx: offsetBy(rect.maxX), dy: offsetBy(rect.maxX)) + } + + private func offsetBy(_ maxX: CGFloat) -> CGFloat { + maxX * offsetRatio + } +} diff --git a/harmonie/Components/BittenView.swift b/harmonie/Components/BittenView.swift new file mode 100644 index 0000000..d663dfa --- /dev/null +++ b/harmonie/Components/BittenView.swift @@ -0,0 +1,47 @@ +// +// BittenView.swift +// Harmonie +// +// Created by xili on 2024/09/18. +// + +import SwiftUI + +struct BittenView: View where Content: View { + @State private var size: CGSize? + private let content: Content + private let ratio: CGFloat + + init(ratio: CGFloat = 0.4, @ViewBuilder content: () -> Content) { + self.content = content() + self.ratio = ratio + } + + private var transformed: CGSize { + guard let size = size else { return .zero } + return CGSize(width: size.width * ratio, height: size.height * ratio) + } + + var body: some View { + content + .mask( + BittenCircleMask(biteSize: transformed) + .fill(style: FillStyle(eoFill: true)) + ) + .background { + GeometryReader { geometry in + Color.clear.onAppear { + size = geometry.size + } + } + } + } +} + +#Preview { + BittenView { + Circle() + .fill(.blue) + .frame(width: 120, height: 120) + } +} diff --git a/harmonie/Components/SectionView.swift b/harmonie/Components/SectionView.swift index 4a6c002..e1ac346 100644 --- a/harmonie/Components/SectionView.swift +++ b/harmonie/Components/SectionView.swift @@ -8,10 +8,12 @@ import SwiftUI struct SectionView: View where Content: View { - var content: Content + private let content: Content + init(@ViewBuilder content: () -> Content) { self.content = content() } + var body: some View { VStack(alignment: .leading) { content diff --git a/harmonie/Components/StatusIndicator.swift b/harmonie/Components/StatusIndicator.swift new file mode 100644 index 0000000..97e9070 --- /dev/null +++ b/harmonie/Components/StatusIndicator.swift @@ -0,0 +1,52 @@ +// +// StatusIndicator.swift +// Harmonie +// +// Created by xili on 2024/09/19. +// + +import SwiftUI + +struct StatusIndicator: View where S: ShapeStyle { + private let content: S + private let isCutOut: Bool + private let outerSize: CGSize + + init(_ content: S, outerSize: CGSize, isCutOut: Bool = false) { + self.content = content + self.outerSize = outerSize + self.isCutOut = isCutOut + } + + private var frameSize: CGSize { + outerSize * 0.3 + } + + private var cutoutSize: CGSize { + outerSize * 0.15 + } + + private var offset: CGSize { + outerSize * 0.36 + } + + var body: some View { + Circle() + .fill(content) + .frame(size: frameSize) + .overlay { + Circle() + .blendMode(.destinationOut) + .frame(size: isCutOut ? cutoutSize : .zero) + } + .compositingGroup() + .offset(x: offset.width, y: offset.height) + } +} + +#Preview { + StatusIndicator( + .blue, + outerSize: CGSize(width: 120, height: 120) + ) +} diff --git a/harmonie/Components/UserIcon.swift b/harmonie/Components/UserIcon.swift new file mode 100644 index 0000000..8464db9 --- /dev/null +++ b/harmonie/Components/UserIcon.swift @@ -0,0 +1,32 @@ +// +// UserIcon.swift +// Harmonie +// +// Created by makinosp on 2024/09/20. +// + +import SwiftUI +import VRCKit + +struct UserIcon: View { + private let user: any ProfileElementRepresentable + private let size: CGSize + + init(user: any ProfileElementRepresentable, size: CGSize) { + self.user = user + self.size = size + } + + var body: some View { + BittenView { + CircleURLImage(imageUrl: user.imageUrl(.x256), size: size) + } + .overlay { + StatusIndicator( + user.status.color, + outerSize: size, + isCutOut: user.platform == .web + ) + } + } +} diff --git a/harmonie/Extensions/CGSize+Operator.swift b/harmonie/Extensions/CGSize+Operator.swift new file mode 100644 index 0000000..098050a --- /dev/null +++ b/harmonie/Extensions/CGSize+Operator.swift @@ -0,0 +1,14 @@ +// +// CGSize+Operator.swift +// Harmonie +// +// Created by makinosp on 2024/09/20. +// + +import CoreGraphics + +extension CGSize { + static func * (lhs: CGSize, rhs: CGFloat) -> CGSize { + CGSize(width: lhs.width * rhs, height: lhs.height * rhs) + } +} diff --git a/harmonie/Views/Favorite/FavoriteFriendListView.swift b/harmonie/Views/Favorite/FavoriteFriendListView.swift index dd5e0d7..6933cc7 100644 --- a/harmonie/Views/Favorite/FavoriteFriendListView.swift +++ b/harmonie/Views/Favorite/FavoriteFriendListView.swift @@ -47,15 +47,7 @@ struct FavoriteFriendListView: View { Label { Text(friend.displayName) } icon: { - ZStack { - Circle() - .foregroundStyle(friend.status.color) - .frame(size: Constants.IconSize.thumbnailOutside) - CircleURLImage( - imageUrl: friend.imageUrl(.x256), - size: Constants.IconSize.thumbnail - ) - } + UserIcon(user: friend, size: Constants.IconSize.thumbnail) } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/harmonie/Views/Friend/FriendsListView.swift b/harmonie/Views/Friend/FriendsListView.swift index f1288cc..421e15e 100644 --- a/harmonie/Views/Friend/FriendsListView.swift +++ b/harmonie/Views/Friend/FriendsListView.swift @@ -29,15 +29,7 @@ struct FriendsListView: View, FriendServicePresentable { Text(friend.displayName) } } icon: { - ZStack { - Circle() - .foregroundStyle(friend.status.color) - .frame(size: Constants.IconSize.thumbnailOutside) - CircleURLImage( - imageUrl: friend.imageUrl(.x256), - size: Constants.IconSize.thumbnail - ) - } + UserIcon(user: friend, size: Constants.IconSize.thumbnail) } } .overlay { overlayView } diff --git a/harmonie/Views/Location/LocationCardView.swift b/harmonie/Views/Location/LocationCardView.swift index accba60..eecb451 100644 --- a/harmonie/Views/Location/LocationCardView.swift +++ b/harmonie/Views/Location/LocationCardView.swift @@ -53,7 +53,10 @@ struct LocationCardView: View, InstanceServicePresentable { ScrollView(.horizontal) { HStack(spacing: -8) { ForEach(location.friends) { friend in - friendThumbnail(friend: friend) + CircleURLImage( + imageUrl: friend.imageUrl(.x256), + size: Constants.IconSize.thumbnail + ) } } } @@ -72,16 +75,4 @@ struct LocationCardView: View, InstanceServicePresentable { .map { $0.description } .joined(separator: " / ") } - - private func friendThumbnail(friend: Friend) -> some View { - ZStack { - Circle() - .foregroundStyle(friend.status.color) - .frame(size: Constants.IconSize.thumbnailOutside) - CircleURLImage( - imageUrl: friend.imageUrl(.x256), - size: Constants.IconSize.thumbnail - ) - } - } } diff --git a/harmonie/Views/Location/LocationDetailView.swift b/harmonie/Views/Location/LocationDetailView.swift index 77ea729..7fdc9fd 100644 --- a/harmonie/Views/Location/LocationDetailView.swift +++ b/harmonie/Views/Location/LocationDetailView.swift @@ -88,15 +88,7 @@ struct LocationDetailView: View { Label { Text(friend.displayName) } icon: { - ZStack { - Circle() - .foregroundStyle(friend.status.color) - .frame(size: Constants.IconSize.thumbnailOutside) - CircleURLImage( - imageUrl: friend.imageUrl(.x256), - size: Constants.IconSize.thumbnail - ) - } + UserIcon(user: friend, size: Constants.IconSize.thumbnail) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) diff --git a/harmonie/Views/Setting/Profile/SettingsView+ProfileSection.swift b/harmonie/Views/Setting/Profile/SettingsView+ProfileSection.swift index ef0c554..5111650 100644 --- a/harmonie/Views/Setting/Profile/SettingsView+ProfileSection.swift +++ b/harmonie/Views/Setting/Profile/SettingsView+ProfileSection.swift @@ -10,31 +10,25 @@ import VRCKit extension SettingsView { func profileSection(user: User) -> some View { - Section(header: Text("My Profile")) { - Button { - destination = .userDetail - } label: { - HStack(alignment: .center) { - Label { - Text(user.displayName) - } icon: { - CircleURLImage( - imageUrl: user.imageUrl(.x256), - size: Constants.IconSize.ll - ) - } - Spacer() + Section(header: Text("Profile")) { + LabeledContent { + if UIDevice.current.userInterfaceIdiom == .phone { Constants.Icon.forward } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) + } label: { + Label { + Text(user.displayName) + } icon: { + UserIcon(user: user, size: Constants.IconSize.ll) + } + .padding(.vertical, 8) } + .tag(Destination.userDetail) + Button { isPresentedForm = true } label: { - HStack(alignment: .center) { - Label("Edit", systemImage: "pencil") - } + Label("Edit", systemImage: "pencil") } } .textCase(nil) diff --git a/harmonie/Views/Setting/SettingsView+AboutSection.swift b/harmonie/Views/Setting/SettingsView+AboutSection.swift index 52e969b..4aec049 100644 --- a/harmonie/Views/Setting/SettingsView+AboutSection.swift +++ b/harmonie/Views/Setting/SettingsView+AboutSection.swift @@ -18,39 +18,35 @@ extension SettingsView { var aboutSection: some View { Section("About") { - Button { - destination = .about + LabeledContent { + Constants.Icon.forward } label: { - LabeledContent { - Constants.Icon.forward - } label: { - Label { - Text("About This App") - } icon: { - Image(systemName: "info.circle") - } - } - } - Link(destination: URL(string: "https://github.com/makinosp/harmonie")!) { Label { - Text("Source Code") + Text("About This App") } icon: { - Image(systemName: "curlybraces") + Image(systemName: "info.circle") } } - Button { - destination = .license - } label: { - LabeledContent { - Constants.Icon.forward - } label: { + .tag(Destination.about) + if let sourceCodeUrl = URL(string: "https://github.com/makinosp/harmonie") { + Link(destination: sourceCodeUrl) { Label { - Text("Third Party Licence") + Text("Source Code") } icon: { - Image(systemName: "lightbulb") + Image(systemName: "curlybraces") } } } + LabeledContent { + Constants.Icon.forward + } label: { + Label { + Text("Third Party Licence") + } icon: { + Image(systemName: "lightbulb") + } + } + .tag(Destination.license) } } diff --git a/harmonie/Views/Setting/SettingsView.swift b/harmonie/Views/Setting/SettingsView.swift index 962b457..094821b 100644 --- a/harmonie/Views/Setting/SettingsView.swift +++ b/harmonie/Views/Setting/SettingsView.swift @@ -11,32 +11,34 @@ import VRCKit struct SettingsView: View, AuthenticationServicePresentable { @Environment(AppViewModel.self) var appVM: AppViewModel - @State var destination: Destination? + @State var destination: Destination? = UIDevice.current.userInterfaceIdiom == .pad ? .userDetail : nil @State var isPresentedForm = false + @State private var columnVisibility: NavigationSplitViewVisibility = .all + + @State private var selectedLibrary: Library? enum Destination: Hashable { case userDetail, about, license } var body: some View { - NavigationSplitView(columnVisibility: .constant(.all)) { + NavigationSplitView(columnVisibility: $columnVisibility) { settingsContent - .navigationDestination(item: $destination) { destination in - presentDestination(destination) - } .navigationTitle("Settings") - } - .navigationSplitViewStyle(.balanced) - .onAppear { - if UIDevice.current.userInterfaceIdiom == .pad { - destination = .userDetail + } detail: { + if let destination = destination { + presentDestination(destination) } } + .navigationSplitViewStyle(.balanced) .sheet(isPresented: $isPresentedForm) { if let user = appVM.user { ProfileEditView(profileEditVM: ProfileEditViewModel(user: user)) } } + .sheet(item: $selectedLibrary) { library in + LicenseView(library: library) + } } @ViewBuilder @@ -49,29 +51,29 @@ struct SettingsView: View, AuthenticationServicePresentable { case .about: aboutThisApp case .license: - LicenseListView() - .navigationTitle("Third Party Licence") - .navigationBarTitleDisplayMode(.inline) + List(Library.libraries) { library in + Button { + selectedLibrary = library + } label: { + Text(library.name) + } + } + .navigationTitle("Third Party Licence") + .navigationBarTitleDisplayMode(.inline) } } private var settingsContent: some View { - List { + List(selection: $destination) { if let user = appVM.user { profileSection(user: user) } aboutSection Section { - AsyncButton { + AsyncButton(role: .destructive) { await appVM.logout(service: authenticationService) } label: { - Label { - Text("Logout") - .foregroundStyle(Color.red) - } icon: { - Image(systemName: "rectangle.portrait.and.arrow.forward") - .foregroundStyle(Color.red) - } + Label("Logout", systemImage: "rectangle.portrait.and.arrow.forward") } } }