From 3d78c599d130ad71eb9a2393a0ef40bf78d3a30f Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 6 Oct 2024 11:50:36 +0000 Subject: [PATCH] [App] Repositories in the Menu Bar view (#4) This PR contains the work done to implement the rendering of the (lack of) repositories in the `MenuBarView` view UI interface. For this purpose, some components have been created: `ListHeader`, `ListItemEmpty`, and `ListItem`. Furthermore, some preview-specific code have been implemented as well, specially to add dummy repositories to the previews. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/piper-app/pulls/4 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Piper.xcodeproj/project.pbxproj | 8 +- .../Resources/Catalogs/Localizable.xcstrings | 56 ++++++++++++ Piper/Sources/App/PiperApp.swift | 30 ++----- .../Extensions/ModelConfiguration+Inits.swift | 30 +++++++ .../Logic/Extensions/Schema+Constants.swift | 20 +++++ Piper/Sources/Logic/Models/Repository.swift | 38 +++++++++ .../Logic/ViewModels/MenuBarViewModel.swift | 20 +++++ .../Extensions/ModelContainer+Constants.swift | 26 ++++++ .../Extensions/Repository+Samples.swift | 36 ++++++++ Piper/Sources/UI/Components/ListHeader.swift | 77 +++++++++++++++++ Piper/Sources/UI/Components/ListItem.swift | 85 +++++++++++++++++++ .../Sources/UI/Components/ListItemEmpty.swift | 61 +++++++++++++ Piper/Sources/UI/Views/MenuBarView.swift | 81 ++++++++++++++++++ Tests/UnitTests/UnitTests.swift | 17 ---- 14 files changed, 542 insertions(+), 43 deletions(-) create mode 100644 Piper/Resources/Catalogs/Localizable.xcstrings create mode 100644 Piper/Sources/Logic/Extensions/ModelConfiguration+Inits.swift create mode 100644 Piper/Sources/Logic/Extensions/Schema+Constants.swift create mode 100644 Piper/Sources/Logic/Models/Repository.swift create mode 100644 Piper/Sources/Logic/ViewModels/MenuBarViewModel.swift create mode 100644 Piper/Sources/Previews/Extensions/ModelContainer+Constants.swift create mode 100644 Piper/Sources/Previews/Extensions/Repository+Samples.swift create mode 100644 Piper/Sources/UI/Components/ListHeader.swift create mode 100644 Piper/Sources/UI/Components/ListItem.swift create mode 100644 Piper/Sources/UI/Components/ListItemEmpty.swift create mode 100644 Piper/Sources/UI/Views/MenuBarView.swift delete mode 100644 Tests/UnitTests/UnitTests.swift diff --git a/Piper.xcodeproj/project.pbxproj b/Piper.xcodeproj/project.pbxproj index 55a9a07..e9c7c3f 100644 --- a/Piper.xcodeproj/project.pbxproj +++ b/Piper.xcodeproj/project.pbxproj @@ -36,7 +36,6 @@ UITests/UITests.swift, UITests/UITestsLaunchTests.swift, UnitTests/Tests/UseCases/RunProcessUseCaseTests.swift, - UnitTests/UnitTests.swift, ); target = 46D4BE762CB06ED300FCFB84 /* Piper */; }; @@ -44,7 +43,6 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( UnitTests/Tests/UseCases/RunProcessUseCaseTests.swift, - UnitTests/UnitTests.swift, ); target = 46D4BED32CB07C7A00FCFB84 /* UnitTests */; }; @@ -363,6 +361,7 @@ ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; @@ -419,6 +418,7 @@ MTL_FAST_MATH = YES; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; }; name = Release; }; @@ -431,7 +431,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = Piper/Resources/Catalogs/Previews.xcassets; + DEVELOPMENT_ASSET_PATHS = "Piper/Sources/Previews/Extensions/Repository+Samples.swift Piper/Sources/Previews/Extensions/ModelContainer+Constants.swift Piper/Resources/Catalogs/Previews.xcassets"; DEVELOPMENT_TEAM = 7FMNM89WKG; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -463,7 +463,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = Piper/Resources/Catalogs/Previews.xcassets; + DEVELOPMENT_ASSET_PATHS = "Piper/Sources/Previews/Extensions/Repository+Samples.swift Piper/Sources/Previews/Extensions/ModelContainer+Constants.swift Piper/Resources/Catalogs/Previews.xcassets"; DEVELOPMENT_TEAM = 7FMNM89WKG; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; diff --git a/Piper/Resources/Catalogs/Localizable.xcstrings b/Piper/Resources/Catalogs/Localizable.xcstrings new file mode 100644 index 0000000..f26d581 --- /dev/null +++ b/Piper/Resources/Catalogs/Localizable.xcstrings @@ -0,0 +1,56 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Add Item" : { + + }, + "Item at %@" : { + + }, + "menu-bar.item.empty.button.text" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add a repository to the list" + } + } + } + }, + "menu-bar.item.empty.title.text" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No repositories have been defined yet." + } + } + } + }, + "menu-bar.section.repositories.button.text" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add" + } + } + } + }, + "menu-bar.section.repositories.title.text" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repositories" + } + } + } + }, + "Select an item" : { + + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Piper/Sources/App/PiperApp.swift b/Piper/Sources/App/PiperApp.swift index b08661e..b8975a9 100644 --- a/Piper/Sources/App/PiperApp.swift +++ b/Piper/Sources/App/PiperApp.swift @@ -14,16 +14,14 @@ struct PiperApp: App { // MARK: Properties - var sharedModelContainer: ModelContainer = { - let schema = Schema([ - Item.self, - ]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) - + private var container = { do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) + return try ModelContainer( + for: .entities, + configurations: [ModelConfiguration()] + ) } catch { - fatalError("Could not create ModelContainer: \(error)") + fatalError("ERROR: ModelContainer has not been created => \(error)") } }() @@ -31,20 +29,8 @@ struct PiperApp: App { var body: some Scene { MenuBarExtra { - VStack(alignment: .leading) { - Text("Some text goes here...") - .foregroundStyle(.primary) - - Divider() - - Button { - // ... - } label: { - Text("Some text goes here...") - .frame(maxWidth: .infinity) - } - } - .padding() + MenuBarView() + .modelContainer(container) } label: { Image(systemName: "circle.fill") } diff --git a/Piper/Sources/Logic/Extensions/ModelConfiguration+Inits.swift b/Piper/Sources/Logic/Extensions/ModelConfiguration+Inits.swift new file mode 100644 index 0000000..8ddcba2 --- /dev/null +++ b/Piper/Sources/Logic/Extensions/ModelConfiguration+Inits.swift @@ -0,0 +1,30 @@ +// +// ModelConfiguration+Inits.swift +// Piper ~ App +// +// Created by Javier Cicchelli on 05/10/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import Foundation +import SwiftData + +extension ModelConfiguration { + + // MARK: Initialisers + + init( + _ name: String? = nil, + schema: Schema = .entities, + url: URL = .applicationSupportDirectory.appending(component: "piper.db") + ) { + self.init( + name, + schema: schema, + url: url, + allowsSave: true, + cloudKitDatabase: .none + ) + } + +} diff --git a/Piper/Sources/Logic/Extensions/Schema+Constants.swift b/Piper/Sources/Logic/Extensions/Schema+Constants.swift new file mode 100644 index 0000000..934a1c5 --- /dev/null +++ b/Piper/Sources/Logic/Extensions/Schema+Constants.swift @@ -0,0 +1,20 @@ +// +// Schema+Constants.swift +// Piper ~ App +// +// Created by Javier Cicchelli on 05/10/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import SwiftData + +extension Schema { + + // MARK: Constants + + static let entities = Schema([ + Repository.self + ]) + + +} diff --git a/Piper/Sources/Logic/Models/Repository.swift b/Piper/Sources/Logic/Models/Repository.swift new file mode 100644 index 0000000..ba64914 --- /dev/null +++ b/Piper/Sources/Logic/Models/Repository.swift @@ -0,0 +1,38 @@ +// +// Repository.swift +// Piper ~ App +// +// Created by Javier Cicchelli on 05/10/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import Foundation +import SwiftData + +@Model +final class Repository { + + // MARK: Properties + + @Attribute(.unique) var path: URL + + var addedAt: Date + var sortOrder: Int + + // MARK: Initialisers + + init( + _ path: URL, + sortOrder: Int, + addedAt: Date = .now + ) { + self.path = path + self.addedAt = addedAt + self.sortOrder = sortOrder + } + + // MARK: Computed + + @Transient var name: String { path.lastPathComponent } + +} diff --git a/Piper/Sources/Logic/ViewModels/MenuBarViewModel.swift b/Piper/Sources/Logic/ViewModels/MenuBarViewModel.swift new file mode 100644 index 0000000..cb04301 --- /dev/null +++ b/Piper/Sources/Logic/ViewModels/MenuBarViewModel.swift @@ -0,0 +1,20 @@ +// +// MenuBarViewModel.swift +// Piper ~ App +// +// Created by Javier Cicchelli on 06/10/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import Observation + +@Observable +final class MenuBarViewModel { + + // MARK: Initialisers + + init () { + + } + +} diff --git a/Piper/Sources/Previews/Extensions/ModelContainer+Constants.swift b/Piper/Sources/Previews/Extensions/ModelContainer+Constants.swift new file mode 100644 index 0000000..21d51a5 --- /dev/null +++ b/Piper/Sources/Previews/Extensions/ModelContainer+Constants.swift @@ -0,0 +1,26 @@ +// +// ModelContainer+Constants.swift +// Piper +// +// Created by Javier Cicchelli on 06/10/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import SwiftData + +extension ModelContainer { + + // MARK: Constants + + static let preview: ModelContainer = { + do { + return try ModelContainer( + for: .entities, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + } catch { + fatalError("Model Container should have been created.") + } + }() + +} diff --git a/Piper/Sources/Previews/Extensions/Repository+Samples.swift b/Piper/Sources/Previews/Extensions/Repository+Samples.swift new file mode 100644 index 0000000..9264b5b --- /dev/null +++ b/Piper/Sources/Previews/Extensions/Repository+Samples.swift @@ -0,0 +1,36 @@ +// +// Repository+Samples.swift +// Piper +// +// Created by Javier Cicchelli on 06/10/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import Foundation +import SwiftData + +extension Repository { + + // MARK: Functions + + @MainActor + static func samples(in container: ModelContainer) { + let context = container.mainContext + + context.insert(Repository( + URL(filePath: "/full/path/to/repository/name-0.git"), + sortOrder: 0 + )) + + context.insert(Repository( + URL(filePath: "/full/path/to/repository/name-1.git"), + sortOrder: 1 + )) + + context.insert(Repository( + URL(filePath: "/full/path/to/repository/name-2.git"), + sortOrder: 2 + )) + } + +} diff --git a/Piper/Sources/UI/Components/ListHeader.swift b/Piper/Sources/UI/Components/ListHeader.swift new file mode 100644 index 0000000..2223432 --- /dev/null +++ b/Piper/Sources/UI/Components/ListHeader.swift @@ -0,0 +1,77 @@ +// +// ListHeader.swift +// Piper ~ App +// +// Created by Javier Cicchelli on 06/10/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import SwiftUI + +struct ListHeader: View { + + // MARK: Properties + + let title: LocalizedStringKey + let button: LocalizedStringKey + let showButton: Bool + let onAction: () -> Void + + // MARK: Body + + var body: some View { + HStack { + Text(title) + .font(.headline) + .foregroundStyle(.primary) + + if showButton { + Spacer() + + Button( + button, + action: onAction + ) + .buttonStyle(.borderless) + } + } + .padding(.vertical) + } + +} + +// MARK: - Previews + +#Preview("List Section component with title") { + List { + Section { + EmptyView() + } header: { + ListHeader( + title: "Some title goes here...", + button: "Some button goes here...", + showButton: false + ) { + // ... + } + } + } + .frame(width: 400, height: 100) +} + +#Preview("List Section component with title and button") { + List { + Section { + EmptyView() + } header: { + ListHeader( + title: "Some title goes here...", + button: "Some button goes here...", + showButton: true + ) { + // ... + } + } + } + .frame(width: 400, height: 100) +} diff --git a/Piper/Sources/UI/Components/ListItem.swift b/Piper/Sources/UI/Components/ListItem.swift new file mode 100644 index 0000000..0b97888 --- /dev/null +++ b/Piper/Sources/UI/Components/ListItem.swift @@ -0,0 +1,85 @@ +// +// ListItem.swift +// Piper ~ App +// +// Created by Javier Cicchelli on 06/10/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import SwiftUI + +struct ListItem: View { + + // MARK: Properties + + let repository: Repository + + // MARK: Body + + var body: some View { + Label { + VStack( + alignment: .leading, + spacing: 4 + ) { + Text(repository.name) + .font(.title) + .fontWeight(.semibold) + .foregroundStyle(.primary) + + Text(repository.path.relativePath) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "folder") + .resizable() + } + .labelStyle(ItemLabelStyle()) + .padding(.vertical, 8) + } + +} + +// MARK: - Label Styles + +struct ItemLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack( + alignment: .center, + spacing: 16 + ) { + configuration.icon + .frame(width: 44) + + configuration.title + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +// MARK: - Previews + +#Preview("List Item component") { + List { + ListItem( + repository: .init( + .init(filePath: "/full/path/to/repository/name.git")!, + sortOrder: 0 + ) + ) + ListItem( + repository: .init( + .init(filePath: "/full/path/to/repository/name.git")!, + sortOrder: 0 + ) + ) + ListItem( + repository: .init( + .init(filePath: "/full/path/to/repository/name.git")!, + sortOrder: 0 + ) + ) + } + .frame(width: 400, height: 220) +} diff --git a/Piper/Sources/UI/Components/ListItemEmpty.swift b/Piper/Sources/UI/Components/ListItemEmpty.swift new file mode 100644 index 0000000..66e4b91 --- /dev/null +++ b/Piper/Sources/UI/Components/ListItemEmpty.swift @@ -0,0 +1,61 @@ +// +// ListItemEmpty.swift +// Piper ~ App +// +// Created by Javier Cicchelli on 06/10/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import SwiftUI + +struct ListItemEmpty: View { + + // MARK: Properties + + let title: LocalizedStringKey + let button: LocalizedStringKey + let onAction: () -> Void + + // MARK: Body + + var body: some View { + GeometryReader { proxy in + Color.clear + .overlay { + VStack(spacing: 16) { + Text(title) + .font(.title) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + + Button(action: onAction) { + Text(button) + .font(.body) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + } + .buttonStyle(.link) + .foregroundStyle(.primary) + } + } + .padding(.horizontal, proxy.size.width * 0.2) + } + } + +} + +// MARK: - Previews + +#Preview("List Item Empty component with title and button") { + List { + ListItemEmpty( + title: "Some title text goes here...", + button: "Some button text goes here..." + ) { + // ... + } + .frame(height: 300) + } + .frame(width: 400, height: 300) +} diff --git a/Piper/Sources/UI/Views/MenuBarView.swift b/Piper/Sources/UI/Views/MenuBarView.swift new file mode 100644 index 0000000..9040121 --- /dev/null +++ b/Piper/Sources/UI/Views/MenuBarView.swift @@ -0,0 +1,81 @@ +// +// MenuBarView.swift +// Piper ~ App +// +// Created by Javier Cicchelli on 06/10/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import SwiftUI +import SwiftData + +struct MenuBarView: View { + + // MARK: Properties + + @Query(sort: \Repository.sortOrder) + private var repositories: [Repository] + + @State private var viewMode = MenuBarViewModel() + + // MARK: Body + + var body: some View { + List { + Section { + if repositories.isEmpty { + ListItemEmpty( + title: "menu-bar.item.empty.title.text", + button: "menu-bar.item.empty.button.text" + ) { + // ... + } + .frame(height: Layout.heightEmpty) + } else { + ForEach(repositories) { + ListItem(repository: $0) + } + } + } header: { + ListHeader( + title: "menu-bar.section.repositories.title.text", + button: "menu-bar.section.repositories.button.text", + showButton: !repositories.isEmpty + ) { + // ... + } + } + } + .frame( + width: Layout.widthView, + height: Layout.heightView + ) + } + +} + +// MARK: - Layout + +private extension MenuBarView { + enum Layout { + static let heightEmpty = CGFloat(200) + static let heightView = CGFloat(260) + static let widthView = CGFloat(400) + } +} + +// MARK: - Previews + +#Preview("Menu Bar view when no repositories found") { + MenuBarView() + .modelContainer(.preview) +} + +#Preview("Menu Bar view when some repositories found") { + let container = ModelContainer.preview + + Repository.samples(in: container) + + return MenuBarView() + .modelContainer(container) +} diff --git a/Tests/UnitTests/UnitTests.swift b/Tests/UnitTests/UnitTests.swift deleted file mode 100644 index c329a8f..0000000 --- a/Tests/UnitTests/UnitTests.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// UnitTests.swift -// UnitTests -// -// Created by Javier Cicchelli on 04/10/2024. -// Copyright © 2024 Röck+Cöde. All rights reserved. -// - -import Testing - -struct UnitTests { - - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - } - -}