├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .swiftlint.yml ├── Documentation ├── Architecture decision records │ ├── Architecture Projet.md │ ├── CloudKit.md │ ├── Open-Source.md │ └── SwiftUI.md └── Autres │ └── Plan réunion 18:08.pdf ├── Images ├── diagram.png └── image1.png ├── LICENSE ├── README.md ├── RunningOrder.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ └── RunningOrder.xcscheme ├── RunningOrder ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ ├── elf green.colorset │ │ └── Contents.json │ ├── epics │ │ ├── Contents.json │ │ ├── anotherBlue.colorset │ │ │ └── Contents.json │ │ ├── blue.colorset │ │ │ └── Contents.json │ │ ├── emeraldGreen.colorset │ │ │ └── Contents.json │ │ ├── gray.colorset │ │ │ └── Contents.json │ │ ├── grayBlue.colorset │ │ │ └── Contents.json │ │ ├── green.colorset │ │ │ └── Contents.json │ │ ├── maroon.colorset │ │ │ └── Contents.json │ │ ├── orange.colorset │ │ │ └── Contents.json │ │ ├── peach.colorset │ │ │ └── Contents.json │ │ ├── pink.colorset │ │ │ └── Contents.json │ │ ├── purple.colorset │ │ │ └── Contents.json │ │ ├── red.colorset │ │ │ └── Contents.json │ │ ├── seaBlue.colorset │ │ │ └── Contents.json │ │ └── yellow.colorset │ │ │ └── Contents.json │ ├── holiday blue.colorset │ │ └── Contents.json │ ├── plus.circle.imageset │ │ ├── Contents.json │ │ └── plus.circle.pdf │ └── snowbank.colorset │ │ └── Contents.json ├── Info.plist ├── Preview Content │ ├── Preview Assets.xcassets │ │ └── Contents.json │ ├── Sprints_Previews.swift │ └── Stories_Previews.swift ├── RunningOrder.entitlements ├── RunningOrderDebug.entitlements ├── Shared │ ├── AppDelegate.swift │ ├── CloudSharingHandler.swift │ ├── Components │ │ ├── BorderedTextField.swift │ │ ├── InlineButtonStyle.swift │ │ ├── InlineEditableLink+Logic.swift │ │ ├── InlineEditableLink.swift │ │ ├── InlineEditableLinkList+Logic.swift │ │ ├── InlineEditableLinkList.swift │ │ ├── InlineEditableList.swift │ │ ├── RoundButton.swift │ │ ├── Search │ │ │ ├── SearchBarSuggestions+Logic.swift │ │ │ ├── SearchBarSuggestions.swift │ │ │ ├── SearchBarView.swift │ │ │ ├── SearchManager.swift │ │ │ └── SuggestionRow.swift │ │ ├── StyledFocusableTextField.swift │ │ └── Tag.swift │ ├── Extensions │ │ ├── Binding+Callback.swift │ │ ├── Cloudkit+Extensions.swift │ │ ├── Colors+Extensions.swift │ │ ├── Combine+Extensions.swift │ │ ├── Image+Extensions.swift │ │ └── Sequence+Extensions.swift │ ├── MainView+Logic.swift │ ├── MainView.swift │ ├── Protocol │ │ ├── CKRecordable.swift │ │ └── TextfieldEditingStringHandler.swift │ ├── Representables │ │ ├── FocusableNSTextField.swift │ │ └── ProgressIndicator.swift │ ├── RunningOrderApp.swift │ ├── ToolbarItems.swift │ └── Utils │ │ ├── BasicError.swift │ │ ├── CloudKitChangesService.swift │ │ ├── CloudKitContainer.swift │ │ ├── Environment+EpicColor.swift │ │ └── Logger.swift ├── Space │ ├── Space.swift │ ├── SpaceManager.swift │ └── SpaceService.swift ├── Sprint │ ├── Extensions │ │ ├── Services │ │ │ └── SprintService.swift │ │ └── Sprint+CloudKit.swift │ ├── Managers │ │ └── SprintManager.swift │ ├── Models │ │ ├── Search.swift │ │ └── Sprint.swift │ ├── Services │ │ └── SprintService.swift │ └── Views │ │ ├── NewSprintView+Logic.swift │ │ ├── NewSprintView.swift │ │ ├── SprintList+Logic.swift │ │ ├── SprintList.swift │ │ └── SprintNumber.swift ├── Story │ ├── Extensions │ │ └── Story+CloudKit.swift │ ├── Managers │ │ ├── StoryInformationManager.swift │ │ └── StoryManager.swift │ ├── Models │ │ ├── Configuration.swift │ │ ├── Link.swift │ │ ├── Story.swift │ │ └── StoryInformation.swift │ ├── Services │ │ ├── StoryInformationService.swift │ │ └── StoryService.swift │ └── Views │ │ ├── ConfigurationView.swift │ │ ├── NewStoryView+Logic.swift │ │ ├── NewStoryView.swift │ │ ├── StepsView.swift │ │ ├── StoryDetail.swift │ │ ├── StoryDetailHeader+Logic.swift │ │ ├── StoryDetailHeader.swift │ │ ├── StoryList+Logic.swift │ │ ├── StoryList.swift │ │ ├── StoryRow.swift │ │ └── VideoView.swift ├── User │ ├── User.swift │ ├── UserReference.swift │ └── UserService.swift ├── WelcomeView.swift ├── en.lproj │ └── Localizable.strings └── fr.lproj │ └── Localizable.strings └── RunningOrderTests ├── Info.plist └── RunningOrderTests.swift /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[REQUEST]" 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | xcuserdata 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: # rule identifiers to exclude from running 2 | - identifier_name 3 | - line_length 4 | - no_space_in_method_call 5 | - multiple_closures_with_trailing_closure 6 | - large_tuple 7 | -------------------------------------------------------------------------------- /Documentation/Architecture decision records/Architecture Projet.md: -------------------------------------------------------------------------------- 1 | # Architecture Projet 2 | #ADR 3 | 4 | ## Introduction 5 | Il existe beaucoup d’architectures logicielles différentes, et au début d’un projet, il est important de choisir une archi adaptée pour éviter des soucis lors du développement : 6 | * Réutilisabilité 7 | * Utilisation simple du Framework principal 8 | * Testing 9 | Le projet utilisant SwiftUI qui est encore très jeune, nous voulions trouver une architecture basée sur ce framework qui utilise principalement des vues contenant des bindings, le tout lié à des objets observés, si besoin de gestion plus complexe. 10 | ## Description détaillée 11 | Le code sera découpé en Vues, Modèles et Managers. 12 | ### Vues 13 | Notre interface est découpée en vues réutilisables. 14 | Elles peuvent utiliser des `@State`, mais uniquement en `private` 15 | ### Modèles 16 | Les structures de données seront des `struct` étendue par des extensions si besoin de conformité à `Identifiable`ou `Codable` ou autre protocol. 17 | ⚠️ Une réflexion à lieu pour mettre en place des business rules au sein d’extension des modèles. Cela sera plus détaillé au terme de la réflexion. 18 | ### Managers 19 | Il s’agit de classes permettant de gérer les données métier et de les fournir aux vues qui ont besoin de ses données. 20 | Plutôt que d’avoir un `ViewModel` par vue, nous avons un manager par type de donnée, ce qui nous permet de les gérer de manière plus globale. 21 | Le passage de ses managers dans les vues se fait via des `environmentObject`afin de descendre la hiérarchie de manière plus simple et d’être utilisable si besoin. 22 | Ces managers peuvent dépendre directement des services ou autre source de données (persistence locale) pour gérer au mieux les informations disponibles. Ils pourront ainsi gérer du cache mais aussi la sauvegarde, les appels réseaux, … 23 | 24 | Cette architecture répond aux besoins suivants : 25 | - Les vues sont réutilisables entre elles, les managers peuvent aussi être utilisés sur toutes les vues les requérant 26 | - Cette architecture est basée sur SwiftUI pour la hiérarchie des vues et sur l’utilisation d’`environmentObject` pour les managers 27 | - Les traitements de données étant isolés, on peut tout à fait mettre en place des tests unitaires simples et efficaces. Les tests UI et le Snapshot testing pourront également être mis à contribution. 28 | ## Arguments 29 | - Facile à mettre en place 30 | - Proche du framework cible 31 | - Répond aux besoins présentés 32 | ## Alternatives considérées 33 | * Faire du MVVM classique : 34 | Concrètement, on aurait donc un `ViewModel` par vue qui serait alors des `ObservedObject` et on perdrait l’intérêt des `@Binding` et `@State` voir meme des `let` dans les vues. De plus, on aurait un VM même si on en a pas besoin, ce qui alourdirait l’app sans raison. 35 | -------------------------------------------------------------------------------- /Documentation/Architecture decision records/CloudKit.md: -------------------------------------------------------------------------------- 1 | # CloudKit 2 | #ADR 3 | 4 | ## Introduction 5 | Nous avons besoin pour l’application Running Order de pouvoir synchroniser en temps réel entre les postes de toute l’équipe toutes les informations du Running Order. 6 | Il faut pouvoir de manière simple ajouter des stories, les modifier et de les lire lors des tests ou de la démo. 7 | ## Arguments 8 | - Pas besoin de serveur => gérer par la plateforme 9 | - Innovant => apprendre à utiliser cette techno 10 | - Intégré à nos outils Xcode 11 | - Gère la synchronisation avec des notifications push silencieuses 12 | - Pas de compte utilisateur à gérer : compte icloud 13 | - Scalable en fonction des utilisateurs 14 | ## Alternatives considérées 15 | * Serveurs chez Worldline 16 | * Problème d’accès par internet : pas d’accès externe 17 | * Continuer sur confluence 18 | * API Confluence à explorer 19 | * Etude de faisabilité nécessaire 20 | * Pas innovant 21 | * CloudKit avec CoreData -------------------------------------------------------------------------------- /Documentation/Architecture decision records/Open-Source.md: -------------------------------------------------------------------------------- 1 | # Open-Source 2 | #ADR 3 | 4 | ## Introduction 5 | ## Arguments 6 | - Plan d’intégration continue adapté (Bitrise, CircleCI,….) 7 | - peut être utile pour d’autres projets 8 | - visibilité de worldline si le projet intéresse 9 | - Parce que c’est cool 10 | ## Alternatives considérées 11 | * Héberger les sources chez Worldline -------------------------------------------------------------------------------- /Documentation/Architecture decision records/SwiftUI.md: -------------------------------------------------------------------------------- 1 | # SwiftUI 2 | #ADR 3 | 4 | ## Introduction 5 | Nous avons besoin d’écrire l’interface de l’app d’une manière simple, tout en innovant 6 | ## Arguments 7 | - Monter en compétence sur cette technologie 8 | - Technologie montante incontournable 9 | - Interaction avec UIKit / AppKit possible si besoin 10 | - Innovant 11 | ## Alternatives considérées 12 | * Non -------------------------------------------------------------------------------- /Documentation/Autres/Plan réunion 18:08.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worldline/RunningOrder/dd3889dee3d4b737ca56c3de8b7d7bda9cb02129/Documentation/Autres/Plan réunion 18:08.pdf -------------------------------------------------------------------------------- /Images/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worldline/RunningOrder/dd3889dee3d4b737ca56c3de8b7d7bda9cb02129/Images/diagram.png -------------------------------------------------------------------------------- /Images/image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worldline/RunningOrder/dd3889dee3d4b737ca56c3de8b7d7bda9cb02129/Images/image1.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Running Order 2 | A macOS SwiftUI app to help demonstrate developed User stories in a sprint. 3 | 4 | ![Image](Images/image1.png?) 5 | 6 | ## Goal 7 | When you work in an Agile team, you need to show the changes of the finished sprint. This app is designed to help a development team organize which feature to show, in which order, with the right information such as links, usernames, environment names, ... 8 | 9 | In order to collaborate with multiple members of the team and respect users’ privacy, all the information is stored in an iCloud private database owned by a member of the team. 10 | 11 | ## Roadmap 12 | Currently this app is at a very early state of development. The first goal is to meet the requirements of one dev team at Worldline, be useful, and prove its value. 13 | 14 | The second goal is to be generalized to other iOS development teams, as the app requires a Mac to work. Past this point, we'll see if we can launch the app on the Mac App Store, to reach development teams outside Worldline. 15 | 16 | Then, develop a counterpart to reach other interested teams (iOS version, Windows version, or web, in order to work on desired platforms) 17 | 18 | ## Technical requirements 19 | Currently the app supports macOS Catalina, but it will be upgraded to Big Sur soon to permit usage of newest SwiftUI changes. 20 | 21 | You'll need an iCloud account on the Mac. (A cloudless functional build is not planned for now, but could be studied later) 22 | 23 | You'll need Xcode 12 to build the app. 24 | 25 | ## Architecture 26 | I wrote an article about the architecture of this project. For now it is under review for publishing in the [Worldline Engineering Blog](https://blog.worldline.tech). 27 | You can also find a diagram that links all Managers, Views, Models, Services [here](Images/diagram.png?) 28 | -------------------------------------------------------------------------------- /RunningOrder.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /RunningOrder.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /RunningOrder.xcodeproj/xcshareddata/xcschemes/RunningOrder.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 65 | 71 | 72 | 73 | 74 | 80 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.478", 10 | "red" : "0.102" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "idiom" : "universal" 23 | } 24 | ], 25 | "info" : { 26 | "author" : "xcode", 27 | "version" : 1 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/elf green.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x5B", 9 | "green" : "0x91", 10 | "red" : "0x23" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.429", 27 | "green" : "0.569", 28 | "red" : "0.284" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/epics/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "properties" : { 7 | "provides-namespace" : true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/epics/anotherBlue.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "osx", 6 | "reference" : "systemTealColor" 7 | }, 8 | "idiom" : "universal" 9 | }, 10 | { 11 | "appearances" : [ 12 | { 13 | "appearance" : "luminosity", 14 | "value" : "dark" 15 | } 16 | ], 17 | "color" : { 18 | "color-space" : "display-p3", 19 | "components" : { 20 | "alpha" : "1.000", 21 | "blue" : "0.585", 22 | "green" : "0.515", 23 | "red" : "0.295" 24 | } 25 | }, 26 | "idiom" : "universal" 27 | } 28 | ], 29 | "info" : { 30 | "author" : "xcode", 31 | "version" : 1 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/epics/blue.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.969", 9 | "green" : "0.511", 10 | "red" : "0.263" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.969", 27 | "green" : "0.655", 28 | "red" : "0.484" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/epics/emeraldGreen.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.365", 9 | "green" : "0.519", 10 | "red" : "0.229" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.381", 27 | "green" : "0.519", 28 | "red" : "0.259" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/epics/gray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.596", 9 | "green" : "0.524", 10 | "red" : "0.487" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.484", 27 | "green" : "0.425", 28 | "red" : "0.395" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/epics/grayBlue.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.467", 9 | "green" : "0.371", 10 | "red" : "0.327" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.376", 27 | "green" : "0.299", 28 | "red" : "0.263" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/epics/green.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.227", 9 | "green" : "0.627", 10 | "red" : "0.438" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.313", 27 | "green" : "0.627", 28 | "red" : "0.479" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/epics/maroon.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.208", 9 | "green" : "0.339", 10 | "red" : "0.520" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.260", 27 | "green" : "0.369", 28 | "red" : "0.520" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/epics/orange.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.208", 9 | "green" : "0.568", 10 | "red" : "0.939" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.470", 27 | "green" : "0.701", 28 | "red" : "0.939" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/epics/peach.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.478", 9 | "green" : "0.582", 10 | "red" : "0.939" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.438", 27 | "green" : "0.533", 28 | "red" : "0.860" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/epics/pink.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.526", 9 | "green" : "0.302", 10 | "red" : "0.740" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.559", 27 | "green" : "0.370", 28 | "red" : "0.740" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/epics/purple.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.643", 9 | "green" : "0.264", 10 | "red" : "0.313" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.643", 27 | "green" : "0.344", 28 | "red" : "0.383" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/epics/red.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.145", 9 | "green" : "0.269", 10 | "red" : "0.802" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.401", 27 | "green" : "0.477", 28 | "red" : "0.802" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/epics/seaBlue.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.773", 9 | "green" : "0.316", 10 | "red" : "0.127" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.773", 27 | "green" : "0.500", 28 | "red" : "0.387" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/epics/yellow.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.239", 9 | "green" : "0.687", 10 | "red" : "0.951" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.475", 27 | "green" : "0.775", 28 | "red" : "0.951" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/holiday blue.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xD5", 9 | "green" : "0xBA", 10 | "red" : "0x2F" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.835", 27 | "green" : "0.767", 28 | "red" : "0.418" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/plus.circle.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "plus.circle.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/plus.circle.imageset/plus.circle.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worldline/RunningOrder/dd3889dee3d4b737ca56c3de8b7d7bda9cb02129/RunningOrder/Assets.xcassets/plus.circle.imageset/plus.circle.pdf -------------------------------------------------------------------------------- /RunningOrder/Assets.xcassets/snowbank.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xE9", 9 | "green" : "0xE9", 10 | "red" : "0xE9" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | CKSharingSupported 24 | 25 | LSMinimumSystemVersion 26 | $(MACOSX_DEPLOYMENT_TARGET) 27 | NSHumanReadableCopyright 28 | Copyright © 2020 Worldline. All rights reserved. 29 | NSSupportsAutomaticTermination 30 | 31 | NSSupportsSuddenTermination 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /RunningOrder/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /RunningOrder/Preview Content/Sprints_Previews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sprints_Previews.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 08/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | extension Sprint { 13 | enum Previews { 14 | static let sprints = [ 15 | Sprint(spaceId: UUID().uuidString, number: 1, name: "Sprint", colorIdentifier: "holiday blue"), 16 | Sprint(spaceId: UUID().uuidString, number: 2, name: "Sprint", colorIdentifier: "elf green") 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /RunningOrder/Preview Content/Stories_Previews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Stories_Previews.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 08/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Story { 12 | enum Previews { 13 | static let stories = [ 14 | Story(sprintId: Sprint.Previews.sprints[0].id, name: "Liste des sprints", ticketReference: "TICKET-1", epic: "Epic 1", creatorReference: nil), 15 | Story(sprintId: Sprint.Previews.sprints[0].id, name: "Créer un sprint", ticketReference: "TICKET-2", epic: "Epic 2", creatorReference: nil), 16 | Story(sprintId: Sprint.Previews.sprints[0].id, name: "Créer une story", ticketReference: "TICKET-3", epic: "Epic 2", creatorReference: nil), 17 | Story(sprintId: Sprint.Previews.sprints[0].id, name: "modifier une story", ticketReference: "TICKET-4", epic: "Epic 3", creatorReference: nil) 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /RunningOrder/RunningOrder.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /RunningOrder/RunningOrderDebug.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.aps-environment 6 | development 7 | com.apple.developer.icloud-container-identifiers 8 | 9 | iCloud.com.worldline.RunningOrder 10 | 11 | com.apple.developer.icloud-services 12 | 13 | CloudKit 14 | 15 | com.apple.security.app-sandbox 16 | 17 | com.apple.security.files.user-selected.read-only 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /RunningOrder/Shared/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 23/06/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import CloudKit 11 | import Combine 12 | import UserNotifications 13 | 14 | class AppDelegate: NSObject, NSApplicationDelegate { 15 | 16 | private var cancellables = Set() 17 | 18 | let cloudkitContainer = CloudKitContainer.shared 19 | var spaceManager: SpaceManager? 20 | weak var changesService: CloudKitChangesService? 21 | 22 | func applicationDidFinishLaunching(_ aNotification: Notification) { 23 | registerForPushNotification() 24 | } 25 | 26 | func application(_ application: NSApplication, userDidAcceptCloudKitShareWith metadata: CKShare.Metadata) { 27 | spaceManager?.acceptShare(metadata: metadata) 28 | } 29 | 30 | func application(_ application: NSApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { 31 | Logger.error.log(error) 32 | } 33 | 34 | func application(_ application: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { 35 | Logger.verbose.log("notifications token : \(deviceToken)") 36 | } 37 | 38 | private func registerForPushNotification() { 39 | UNUserNotificationCenter.current().requestAuthorization(options: []) { granted, error in 40 | if let error = error { 41 | Logger.error.log(error) 42 | } else { 43 | Logger.verbose.log("notifications grant status : \(granted)") 44 | } 45 | } 46 | NSApplication.shared.registerForRemoteNotifications() 47 | } 48 | 49 | @IBAction func deleteSpace(sender: Any) { 50 | guard let spaceManager = spaceManager else { return } 51 | 52 | spaceManager.deleteCurrentSpace() 53 | // TODO: delete local cache 54 | cloudkitContainer.resetModeIfNeeded() 55 | } 56 | 57 | func application(_ application: NSApplication, didReceiveRemoteNotification userInfo: [String: Any]) { 58 | guard cloudkitContainer.validateNotification(userInfo) else { return } 59 | 60 | changesService?.fetchChanges() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /RunningOrder/Shared/CloudSharingHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudSharingHandler.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 02/02/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import CloudKit 10 | import Combine 11 | import AppKit 12 | 13 | class CloudSharingHandler: NSObject { 14 | init(spaceManager: SpaceManager) { 15 | self.spaceManager = spaceManager 16 | } 17 | 18 | let spaceManager: SpaceManager 19 | private var share: CKShare? 20 | 21 | var cancellables = Set() 22 | 23 | func performCloudSharing() { 24 | guard let space = self.spaceManager.space else { return } 25 | 26 | if space.isShared { 27 | displayAlreadySharing() 28 | } else { 29 | displayNewShare() 30 | } 31 | } 32 | 33 | func displayNewShare() { 34 | let itemProvider = NSItemProvider() 35 | let container = CloudKitContainer.shared.container 36 | 37 | itemProvider.registerCloudKitShare { [weak self] completion in 38 | guard let self = self else { return } 39 | 40 | self.spaceManager.saveAndShare() 41 | .sink(receiveFailure: { error in 42 | completion(nil, container, error) 43 | }, receiveValue: { [weak self] share in 44 | self?.share = share 45 | completion(share, container, nil) 46 | }) 47 | .store(in: &self.cancellables) 48 | } 49 | 50 | let sharingService = NSSharingService(named: .cloudSharing)! 51 | sharingService.delegate = self 52 | sharingService.perform(withItems: [itemProvider]) 53 | } 54 | 55 | func displayAlreadySharing() { 56 | self.spaceManager.getShare() 57 | .receive(on: DispatchQueue.main) 58 | .sink(receiveFailure: { error in 59 | Logger.error.log(error) 60 | }, receiveValue: { [weak self] share in 61 | self?.share = share 62 | let itemProvider = NSItemProvider() 63 | itemProvider.registerCloudKitShare(share, container: CloudKitContainer.shared.container) 64 | 65 | let sharingService = NSSharingService(named: .cloudSharing)! 66 | sharingService.delegate = self 67 | sharingService.perform(withItems: [itemProvider]) 68 | }) 69 | .store(in: &cancellables) 70 | } 71 | } 72 | 73 | extension CloudSharingHandler: NSCloudSharingServiceDelegate {} 74 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Components/BorderedTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BorderedTextField.swift 3 | // RunningOrder 4 | // 5 | // Created by Loic B on 16/04/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct BorderedTextField: View { 12 | let placeholder: String 13 | @Binding var value: String 14 | 15 | var body: some View { 16 | FocusableTextField(placeholder: placeholder, value: $value, isFocused: .constant(false), onCommit: {}) 17 | .padding(5) 18 | // on focus background 19 | .background(RoundedRectangle(cornerRadius: 10) 20 | .foregroundColor(Color(NSColor.textBackgroundColor)) 21 | ).textFieldStyle(PlainTextFieldStyle()) 22 | // on focus border 23 | .overlay(RoundedRectangle(cornerRadius: 10) 24 | .strokeBorder(Color.accentColor, lineWidth: 1.0, antialiased: true) 25 | ) 26 | } 27 | } 28 | 29 | struct BorderedTextField_Previews: PreviewProvider { 30 | static var previews: some View { 31 | BorderedTextField(placeholder: "Label", value: .constant("")) 32 | .padding(10) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Components/InlineButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InlineButtonStyle.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 02/02/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct InlineButtonStyle: ButtonStyle { 12 | func makeBody(configuration: ButtonStyle.Configuration) -> some View { 13 | configuration.label 14 | .frame(width: 17, height: 17) 15 | .padding(.horizontal, 10) 16 | .buttonStyle(PlainButtonStyle()) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Components/InlineEditableLink+Logic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InlineEditableLink+Logic.swift 3 | // RunningOrder 4 | // 5 | // Created by Loic B on 21/04/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import SwiftUI 12 | 13 | extension InlineEditableLink { 14 | final class Logic: ObservableObject { 15 | @Binding var value: Link 16 | 17 | var isFieldEmpty: Bool { 18 | return value.url.isEmpty || value.label.isEmpty 19 | } 20 | 21 | init(value: Binding) { 22 | self._value = value 23 | } 24 | 25 | func formatURL(content: String) -> String { 26 | var url = content 27 | if !value.url.contains("https://") && !value.url.contains("http://") { 28 | url.insert(contentsOf: "https://", at: value.url.startIndex) 29 | } 30 | return url 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Components/InlineEditableLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InlineEditableLink.swift 3 | // RunningOrder 4 | // 5 | // Created by Loic B on 26/02/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct InlineEditableLink: View { 12 | @State private var hovered = false 13 | @State private var isEditing: Bool 14 | @State private var isDoneButtonEnabled = true 15 | @ObservedObject var logic: Logic 16 | 17 | /// - Parameters: 18 | /// - value: A binding to a Link Entity which referers to the Link url and label 19 | init(value: Binding) { 20 | self._isEditing = State(initialValue: value.wrappedValue.label.isEmpty) 21 | self.logic = Logic(value: value) 22 | } 23 | 24 | var body: some View { 25 | VStack(alignment: .leading) { 26 | HStack { 27 | if isEditing { 28 | VStack { 29 | BorderedTextField(placeholder: "Label", value: logic.$value.label) 30 | BorderedTextField(placeholder: "Url", value: logic.$value.url) 31 | } 32 | Spacer() 33 | Button(action: { 34 | if !logic.isFieldEmpty { 35 | self.isEditing = false 36 | logic.value.url = logic.formatURL(content: logic.value.url) 37 | } 38 | }, label: { 39 | Text("Done") 40 | }) 41 | .buttonStyle(PlainButtonStyle()) 42 | .foregroundColor(.accentColor) 43 | .disabled(logic.isFieldEmpty) 44 | } else { 45 | HStack { 46 | if let url = logic.value.formattedURL { 47 | SwiftUI.Link(logic.value.label, destination: url) 48 | Spacer() 49 | if hovered { 50 | Button(action: { 51 | self.isEditing = true 52 | }, label: { 53 | Text("Edit") 54 | }) 55 | .buttonStyle(PlainButtonStyle()) 56 | .foregroundColor(.accentColor) 57 | } 58 | } 59 | } 60 | .padding(5) 61 | } 62 | } 63 | } 64 | .padding(5) 65 | .background( 66 | Color(identifier: .snowbank) 67 | .opacity(hovered ? 1 : 0) 68 | .cornerRadius(5) 69 | ) 70 | .onHover { isHovered in 71 | withAnimation(.easeIn) { 72 | self.hovered = isHovered 73 | } 74 | } 75 | } 76 | } 77 | 78 | struct InlineEditableLink_Previews: PreviewProvider { 79 | static var previews: some View { 80 | InlineEditableLink(value: .constant(Link(label: "", url: ""))) 81 | InlineEditableLink(value: .constant(Link(label: "Label", url: "url"))) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Components/InlineEditableLinkList+Logic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InlineEditableLinkList+Logic.swift 3 | // RunningOrder 4 | // 5 | // Created by Loic B on 16/04/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import SwiftUI 12 | 13 | extension InlineEditableLinkList { 14 | final class Logic: ObservableObject { 15 | @Binding var values: [Link] 16 | 17 | init(values: Binding<[Link]>) { 18 | self._values = values 19 | } 20 | 21 | func addLinkTextField() { 22 | values.append(Link(label: "", url: "")) 23 | } 24 | 25 | func deleteTextField(at index: Int) { 26 | //sanity check to prevent some out of bounds exception 27 | guard index < values.count else { return } 28 | _ = values.remove(at: index) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Components/InlineEditableLinkList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InlineEditableLinkList.swift 3 | // RunningOrder 4 | // 5 | // Created by Loic B on 08/04/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct InlineEditableLinkList: View { 12 | let title: LocalizedStringKey 13 | 14 | @State private var hovered = false 15 | @ObservedObject var logic: Logic 16 | 17 | init(title: LocalizedStringKey, values: Binding<[Link]>) { 18 | self.title = title 19 | self.logic = Logic(values: values) 20 | } 21 | 22 | var body: some View { 23 | VStack(spacing: 0) { 24 | HStack { 25 | Text(title) 26 | .font(.title2) 27 | .padding(.leading, 7) 28 | 29 | Spacer() 30 | 31 | Button( 32 | action: logic.addLinkTextField, 33 | label: { Image(systemName: "plus.circle.fill") } 34 | ) 35 | .foregroundColor(.blue) 36 | .buttonStyle(InlineButtonStyle()) 37 | } 38 | .padding(.bottom, 10) 39 | 40 | ForEach(logic.values.indices, id: \.self) { index in 41 | HStack { 42 | InlineEditableLink( 43 | value: Binding( 44 | get: { return logic.values[index] }, 45 | set: { newValue in return self.logic.values[index] = newValue } 46 | ) 47 | ) 48 | Button( 49 | action: { logic.deleteTextField(at: index) }, 50 | label: { Image(systemName: "minus.circle.fill") } 51 | ) 52 | .foregroundColor(.red) 53 | .buttonStyle(InlineButtonStyle()) 54 | } 55 | } 56 | } 57 | .padding(5) 58 | .background( 59 | Color(identifier: .snowbank) 60 | .opacity(hovered ? 1 : 0) 61 | .cornerRadius(5) 62 | ) 63 | } 64 | } 65 | 66 | struct InlineEditableLinkList_Previews: PreviewProvider { 67 | static var previews: some View { 68 | InlineEditableLinkList(title: "Add a link", values: .constant([Link(label: "", url: "")])) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Components/InlineEditableList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InlineTexField.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 20/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import AppKit 11 | 12 | /// Display an inline editable list of string element 13 | struct InlineEditableList: View { 14 | 15 | let title: LocalizedStringKey 16 | 17 | let placeholder: String 18 | 19 | @Binding var values: [String] 20 | @State private var hovered = false 21 | 22 | /// - Parameters: 23 | /// - title: The title to describe the editable list 24 | /// - placeholder: The placeholder to put in every list items 25 | /// - values: A binding to a string array which referers to the list values 26 | init(title: LocalizedStringKey, placeholder: String = "", values: Binding<[String]>) { 27 | self.title = title 28 | self._values = values 29 | self.placeholder = placeholder 30 | } 31 | 32 | var body: some View { 33 | VStack(spacing: 0) { 34 | HStack { 35 | Text(title) 36 | .font(.headline) 37 | .padding(.leading, 7) 38 | 39 | Spacer() 40 | 41 | if hovered { 42 | Button( 43 | action: addTextfieldValue, 44 | label: { Image(systemName: "plus.circle.fill") } 45 | ) 46 | .foregroundColor(.blue) 47 | .buttonStyle(InlineButtonStyle()) 48 | } 49 | } 50 | 51 | ForEach(values.indices, id: \.self) { index in 52 | ZStack(alignment: .trailing) { 53 | StyledFocusableTextField(placeholder, value: Binding( 54 | get: { return values[index] }, 55 | set: { newValue in return self.values[index] = newValue } 56 | ), 57 | onCommit: { 58 | // we delete the field if its value is empty 59 | if values[index].isEmpty { 60 | deleteTextfieldValue(at: index) 61 | } 62 | }) 63 | if hovered { 64 | Button( 65 | action: { deleteTextfieldValue(at: index) }, 66 | label: { Image(systemName: "minus.circle.fill") } 67 | ) 68 | .foregroundColor(.red) 69 | .buttonStyle(InlineButtonStyle()) 70 | } 71 | } 72 | .padding(.vertical, 4) 73 | } 74 | } 75 | .padding(5) 76 | .background( 77 | Color(identifier: .snowbank) 78 | .opacity(hovered ? 1 : 0) 79 | .cornerRadius(5) 80 | ) 81 | .onHover { isHovered in 82 | withAnimation(.easeIn) { 83 | self.hovered = isHovered 84 | } 85 | } 86 | } 87 | 88 | private func addTextfieldValue() { 89 | withAnimation { 90 | values.append("") 91 | } 92 | } 93 | 94 | private func deleteTextfieldValue(at index: Int) { 95 | //sanity check to prevent some out of bounds exception 96 | guard index < values.count else { return } 97 | 98 | withAnimation { 99 | _ = values.remove(at: index) 100 | } 101 | } 102 | } 103 | 104 | struct InlineTexField_Previews: PreviewProvider { 105 | static var previews: some View { 106 | InlineEditableList(title: "A Title", placeholder: "A Placeholder for all my fields", values: .constant(["value1", "value2", ""])) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Components/RoundButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoundButton`.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 24/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// A simple colored round button with an image in it 12 | struct RoundButton: View { 13 | 14 | let image: Image 15 | let color: Color 16 | let action: () -> Void 17 | 18 | var body: some View { 19 | Button(action: self.action) { 20 | GeometryReader(content: { geometry in 21 | image 22 | .resizable() 23 | .padding(geometry.size.width * 0.2) 24 | .background(color) 25 | .clipShape(Circle()) 26 | }) 27 | } 28 | .foregroundColor(.white) 29 | .buttonStyle(PlainButtonStyle()) 30 | } 31 | } 32 | 33 | struct RoundButton_Previews: PreviewProvider { 34 | static var previews: some View { 35 | RoundButton(image: Image(nsImageName: NSImage.refreshTemplateName), color: Color.green, action: {}) 36 | .frame(width: 40, height: 40) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Components/Search/SearchBarSuggestions+Logic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchBarSuggestions+Logic.swift 3 | // RunningOrder 4 | // 5 | // Created by Ghita Laoud on 26/03/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import SwiftUI 12 | 13 | extension SearchBarSuggestions { 14 | final class Logic: ObservableObject { 15 | @Binding var input: String 16 | private unowned var storyManager: StoryManager 17 | unowned var searchManager: SearchManager 18 | 19 | init(input: Binding, storyManager: StoryManager, searchManager: SearchManager) { 20 | self._input = input 21 | self.storyManager = storyManager 22 | self.searchManager = searchManager 23 | } 24 | 25 | var filteredStories: [Story] { 26 | let stories = storyManager.allStories.filter {$0.name.lowercased().contains(input.lowercased()) || $0.epic.lowercased().contains(input.lowercased()) || $0.ticketReference.lowercased().contains(input.lowercased()) 27 | } 28 | 29 | return stories 30 | } 31 | 32 | var filteredSearchSections: [SearchSection] { 33 | let formattedStories = filteredStories.map { story -> SearchItem in 34 | SearchItem(name: "\(story.name)", icon: SearchSection.SectionType.story.icon, type: .story, relatedStory: story) 35 | } 36 | 37 | let filteredEpics = filteredStories.map { 38 | SearchItem(name: $0.epic, icon: SearchSection.SectionType.epic.icon, type: .epic, relatedStory: nil) 39 | } 40 | 41 | let sections = [SearchSection(type: SearchSection.SectionType.story, items: Set(formattedStories)), 42 | SearchSection(type: SearchSection.SectionType.epic, items: Set(filteredEpics))] 43 | 44 | return sections 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Components/Search/SearchBarSuggestions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchBarSuggestions.swift 3 | // RunningOrder 4 | // 5 | // Created by Ghita Laoud on 25/03/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SearchBarSuggestions: View { 12 | @EnvironmentObject var storyManager: StoryManager 13 | @EnvironmentObject var searchManager: SearchManager 14 | 15 | @Binding var searchText: String 16 | var body: some View { 17 | InternalView(logic: Logic(input: $searchText, storyManager: storyManager, searchManager: searchManager)) 18 | } 19 | } 20 | 21 | extension SearchBarSuggestions { 22 | fileprivate struct InternalView: View { 23 | @ObservedObject var logic: Logic 24 | 25 | var body: some View { 26 | ScrollView { 27 | VStack(alignment: .leading) { 28 | if !logic.filteredStories.isEmpty { 29 | ForEach(logic.filteredSearchSections, id: \.id) { section in 30 | Section(header: Text(section.type.title).font(.headline).bold()) { 31 | Divider() 32 | 33 | ForEach(Array(section.items)) { item in 34 | SuggestionRow(imageName: item.icon, suggestion: item.name) 35 | .onTapGesture { 36 | logic.searchManager.selectedSearchItem = item 37 | } 38 | } 39 | } 40 | } 41 | } else { 42 | Label("No matching stories, epics found", systemImage: "magnifyingglass") 43 | .padding() 44 | } 45 | }.padding(8) 46 | }.frame(width: 300, height: 300) 47 | } 48 | } 49 | } 50 | 51 | struct SearchBarSuggestions_Previews: PreviewProvider { 52 | static var previews: some View { 53 | SearchBarSuggestions(searchText: .constant("")) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Components/Search/SearchBarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchBarView.swift 3 | // RunningOrder 4 | // 5 | // Created by Ghita Laoud on 21/03/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SearchBarView: View { 12 | @State private var inputText: String = "" 13 | @State private var disableTextField = false 14 | @EnvironmentObject var searchManager: SearchManager 15 | 16 | var body: some View { 17 | HStack { 18 | ZStack(alignment: .leading) { 19 | if let selected = searchManager.selectedSearchItem?.name { 20 | Tag("\(selected)", color: Color(identifier: .gray).opacity(0.25), foregroundTextColor: Color.black) 21 | .padding(.trailing, 22) 22 | .padding(.leading, 5) 23 | .onAppear(perform: { 24 | inputText = "" 25 | disableTextField = true 26 | }) 27 | } 28 | TextField(searchManager.isItemSelected ? "" : "Search", text: $inputText).disabled(disableTextField) 29 | } 30 | 31 | .overlay( 32 | HStack { 33 | Spacer() 34 | if !inputText.isEmpty || disableTextField { 35 | Button(action: { 36 | self.inputText = "" 37 | searchManager.selectedSearchItem = nil 38 | disableTextField = false 39 | }) { 40 | Image(systemName: "multiply.circle.fill") 41 | .foregroundColor(.gray) 42 | .padding(.horizontal, 8) 43 | } 44 | .buttonStyle(PlainButtonStyle()) 45 | } 46 | } 47 | ) 48 | .textFieldStyle(RoundedBorderTextFieldStyle()) 49 | } 50 | .popover(isPresented: Binding(get: { !inputText.isEmpty && !(searchManager.isItemSelected)}, 51 | set: { _ in})) { 52 | SearchBarSuggestions(searchText: $inputText) 53 | } 54 | } 55 | } 56 | struct SearchBarView_Previews: PreviewProvider { 57 | static var previews: some View { 58 | SearchBarView() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Components/Search/SearchManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchManager.swift 3 | // RunningOrder 4 | // 5 | // Created by Ghita Laoud on 06/04/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import SwiftUI 12 | 13 | final class SearchManager: ObservableObject { 14 | @Published var selectedSearchItem: SearchItem? 15 | 16 | var isItemSelected: Bool { 17 | selectedSearchItem != nil 18 | } 19 | 20 | var selectedItemType: SearchSection.SectionType? { 21 | return selectedSearchItem?.type 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Components/Search/SuggestionRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SuggestionRow.swift 3 | // RunningOrder 4 | // 5 | // Created by Ghita Laoud on 26/03/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SuggestionRow: View { 12 | let imageName: String 13 | let suggestion: String 14 | 15 | var body: some View { 16 | HStack(alignment: .firstTextBaseline, spacing: 8) { 17 | Image(systemName: imageName) 18 | Text(suggestion) 19 | } 20 | .padding(5) 21 | } 22 | } 23 | 24 | struct SuggestionRow_Previews: PreviewProvider { 25 | static var previews: some View { 26 | SuggestionRow(imageName: "person.circle", suggestion: "FPL-11999") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Components/StyledFocusableTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StyledFocusableTextField.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 24/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// A styled focusable version of a Textfield based on NSTextField 12 | struct StyledFocusableTextField: View { 13 | 14 | let placeholder: String 15 | 16 | @State private var isFocused: Bool = false 17 | @Binding var value: String 18 | 19 | private var borderOpacity: Double { isFocused ? 1 : 0 } 20 | 21 | let onCommit: () -> Void 22 | 23 | /// - Parameters: 24 | /// - placeholder: the textfield placeholder 25 | /// - value: the binding to the textfield value 26 | /// - onCommit: the action to perform when the user hits the return key or when the component looses the focus 27 | init(_ placeholder: String, value: Binding, onCommit: @escaping () -> Void) { 28 | self.placeholder = placeholder 29 | self._value = value 30 | self.onCommit = onCommit 31 | } 32 | 33 | var body: some View { 34 | FocusableTextField(placeholder: placeholder, value: $value, isFocused: $isFocused, onCommit: onCommit) 35 | .padding(5) 36 | // on focus background 37 | .background(RoundedRectangle(cornerRadius: 10) 38 | .foregroundColor(Color(NSColor.textBackgroundColor).opacity(borderOpacity)) 39 | .animation(.linear) 40 | ) 41 | // on focus border 42 | .overlay(RoundedRectangle(cornerRadius: 10) 43 | .strokeBorder(Color.accentColor, lineWidth: 1.0, antialiased: true) 44 | .opacity(borderOpacity) 45 | .animation(.easeInOut) 46 | ) 47 | .focusable() 48 | } 49 | } 50 | 51 | struct FocusableTextField_Previews: PreviewProvider { 52 | static var previews: some View { 53 | StyledFocusableTextField("Placeholder", value: .constant(""), onCommit: {}) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Components/Tag.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tag.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 30/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct Tag: View { 12 | 13 | let text: String 14 | let color: Color 15 | let foregroundTextColor: Color 16 | 17 | init(_ text: String, color: Color, foregroundTextColor: Color = Color.white) { 18 | self.text = text 19 | self.color = color 20 | self.foregroundTextColor = foregroundTextColor 21 | } 22 | 23 | var body: some View { 24 | Text(text) 25 | .foregroundColor(foregroundTextColor) 26 | .padding(.horizontal, 8) 27 | .padding(.vertical, 2) 28 | .background(color) 29 | .clipShape(RoundedRectangle(cornerRadius: 4)) 30 | } 31 | } 32 | 33 | struct Tag_Previews: PreviewProvider { 34 | static var previews: some View { 35 | Tag("A Tag", color: Color(identifier: .holidayBlue)) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Extensions/Binding+Callback.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Binding+Callback.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 19/02/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | extension Binding { 12 | init(callback: @escaping (Wrapped) -> Void) where Value == Wrapped? { 13 | self.init( 14 | get: { return nil }, 15 | set: { newValue in 16 | if let newValue = newValue { 17 | callback(newValue) 18 | } 19 | } 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Extensions/Cloudkit+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cloudkit+Extensions.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 20/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import CloudKit 12 | 13 | extension CKQueryOperation { 14 | /// Combine publisher of an CKQueryOperation recordFetchedBlock completion block 15 | /// Each iteration in the completion block will result in a value sent by the publisher 16 | func publishers() -> (recordFetched: AnyPublisher, completion: AnyPublisher) { 17 | let operationPublisher = PassthroughSubject() 18 | 19 | self.recordFetchedBlock = { record in 20 | operationPublisher.send(record) 21 | } 22 | 23 | let completionPublisher = PassthroughSubject() 24 | 25 | self.queryCompletionBlock = { (cursor, error) in 26 | if let error = error { 27 | operationPublisher.send(completion: .failure(error)) 28 | completionPublisher.send(completion: .failure(error)) 29 | } else { 30 | operationPublisher.send(completion: .finished) 31 | completionPublisher.send(cursor) 32 | completionPublisher.send(completion: .finished) 33 | } 34 | } 35 | return (operationPublisher.eraseToAnyPublisher(), completionPublisher.eraseToAnyPublisher()) 36 | } 37 | } 38 | 39 | extension CKModifyRecordsOperation { 40 | /// Wrapper for block based events sent inside Modify Records Operations. 41 | func publishers() -> (perRecordProgress: AnyPublisher<(CKRecord, Double), Error>, perRecord: AnyPublisher, completion: AnyPublisher<([CKRecord]?, [CKRecord.ID]?), Error>) { 42 | let perRecord = PassthroughSubject() 43 | self.perRecordCompletionBlock = { (record, error) in 44 | if let error = error { 45 | perRecord.send(completion: .failure(error)) 46 | } else { 47 | perRecord.send(record) 48 | } 49 | } 50 | 51 | let perRecordProgress = PassthroughSubject<(CKRecord, Double), Error>() 52 | self.perRecordProgressBlock = { record, progress in 53 | perRecordProgress.send((record, progress)) 54 | } 55 | 56 | let completion = PassthroughSubject<([CKRecord]?, [CKRecord.ID]?), Error>() 57 | self.modifyRecordsCompletionBlock = { records, deletedIds, error in 58 | Logger.debug.log("publisher - modify - completionBlock ok") 59 | completion.send((records, deletedIds)) 60 | if let error = error { 61 | perRecord.send(completion: .failure(error)) 62 | perRecordProgress.send(completion: .failure(error)) 63 | completion.send(completion: .failure(error)) 64 | } else { 65 | perRecord.send(completion: .finished) 66 | perRecordProgress.send(completion: .finished) 67 | completion.send(completion: .finished) 68 | } 69 | } 70 | 71 | return (perRecordProgress.eraseToAnyPublisher(), perRecord.eraseToAnyPublisher(), completion.eraseToAnyPublisher()) 72 | } 73 | } 74 | 75 | extension CKAcceptSharesOperation { 76 | func publishers() -> (perShare: AnyPublisher<(CKShare.Metadata, CKShare?), Error>, acceptShares: AnyPublisher) { 77 | let perShare = PassthroughSubject<(CKShare.Metadata, CKShare?), Error>() 78 | self.perShareCompletionBlock = { metadata, share, error in 79 | if let error = error { 80 | perShare.send(completion: .failure(error)) 81 | } else { 82 | perShare.send((metadata, share)) 83 | } 84 | } 85 | 86 | let acceptShares = PassthroughSubject() 87 | self.acceptSharesCompletionBlock = { error in 88 | if let error = error { 89 | perShare.send(completion: .failure(error)) 90 | acceptShares.send(completion: .failure(error)) 91 | } else { 92 | perShare.send(completion: .finished) 93 | acceptShares.send(completion: .finished) 94 | } 95 | } 96 | 97 | return (perShare.eraseToAnyPublisher(), acceptShares.eraseToAnyPublisher()) 98 | } 99 | } 100 | 101 | extension CKFetchRecordsOperation { 102 | func publishers() -> (perRecordProgress: AnyPublisher<(CKRecord.ID, Double), Error>, perRecord: AnyPublisher<(CKRecord?, CKRecord.ID?), Error>, completion: AnyPublisher<[CKRecord.ID: CKRecord]?, Error>) { 103 | let perRecord = PassthroughSubject<(CKRecord?, CKRecord.ID?), Error>() 104 | self.perRecordCompletionBlock = { (record, id, error) in 105 | if let error = error { 106 | perRecord.send(completion: .failure(error)) 107 | } else { 108 | perRecord.send((record, id)) 109 | } 110 | } 111 | 112 | let perRecordProgress = PassthroughSubject<(CKRecord.ID, Double), Error>() 113 | self.perRecordProgressBlock = { record, double in 114 | perRecordProgress.send((record, double)) 115 | } 116 | 117 | let completion = PassthroughSubject<[CKRecord.ID: CKRecord]?, Error>() 118 | self.fetchRecordsCompletionBlock = { records, error in 119 | completion.send(records) 120 | if let error = error { 121 | perRecord.send(completion: .failure(error)) 122 | perRecordProgress.send(completion: .failure(error)) 123 | completion.send(completion: .failure(error)) 124 | } else { 125 | perRecord.send(completion: .finished) 126 | perRecordProgress.send(completion: .finished) 127 | completion.send(completion: .finished) 128 | } 129 | } 130 | 131 | return (perRecordProgress.eraseToAnyPublisher(), perRecord.eraseToAnyPublisher(), completion.eraseToAnyPublisher()) 132 | } 133 | } 134 | 135 | extension CKModifySubscriptionsOperation { 136 | func publisher() -> AnyPublisher<([CKSubscription]?, [CKSubscription.ID]?), Error> { 137 | let modifySubscriptions = PassthroughSubject<([CKSubscription]?, [CKSubscription.ID]?), Error>() 138 | self.modifySubscriptionsCompletionBlock = { subscriptions, ids, error in 139 | if let error = error { 140 | modifySubscriptions.send(completion: .failure(error)) 141 | return 142 | } 143 | 144 | modifySubscriptions.send((subscriptions, ids)) 145 | modifySubscriptions.send(completion: .finished) 146 | } 147 | 148 | return modifySubscriptions.eraseToAnyPublisher() 149 | } 150 | } 151 | 152 | extension CKFetchRecordZoneChangesOperation { 153 | func publishers() -> (fetchRecordZoneChangesCompletion: AnyPublisher, 154 | recordChanged: AnyPublisher, 155 | recordWithIDWasDeleted: AnyPublisher<(recordId: CKRecord.ID, recordType: CKRecord.RecordType), Never>, 156 | recordZoneChangeTokensUpdated: AnyPublisher<(zoneId: CKRecordZone.ID, serverToken: CKServerChangeToken?, clientToken: Data?), Never>, 157 | recordZoneFetchCompletion: AnyPublisher<(zoneId: CKRecordZone.ID, serverToken: CKServerChangeToken?, clientToken: Data?, isMoreComing: Bool), Error>) { 158 | let recordWithIDWasDeleted = PassthroughSubject<(recordId: CKRecord.ID, recordType: CKRecord.RecordType), Never>() 159 | self.recordWithIDWasDeletedBlock = { id, type in 160 | recordWithIDWasDeleted.send((id, type)) 161 | } 162 | 163 | let recordChanged = PassthroughSubject() 164 | self.recordChangedBlock = { record in 165 | recordChanged.send(record) 166 | } 167 | 168 | let recordZoneChangeTokensUpdated = PassthroughSubject<(zoneId: CKRecordZone.ID, serverToken: CKServerChangeToken?, clientToken: Data?), Never>() 169 | self.recordZoneChangeTokensUpdatedBlock = { id, serverToken, clientToken in 170 | recordZoneChangeTokensUpdated.send((id, serverToken, clientToken)) 171 | } 172 | 173 | let recordZoneFetchCompletion = PassthroughSubject<(zoneId: CKRecordZone.ID, serverToken: CKServerChangeToken?, clientToken: Data?, isMoreComing: Bool), Error>() 174 | self.recordZoneFetchCompletionBlock = { id, serverToken, clientToken, moreComing, error in 175 | if let error = error { 176 | recordZoneFetchCompletion.send(completion: .failure(error)) 177 | } else { 178 | recordZoneFetchCompletion.send((id, serverToken, clientToken, moreComing)) 179 | } 180 | } 181 | 182 | let fetchRecordZoneChangesCompletion = PassthroughSubject() 183 | self.fetchRecordZoneChangesCompletionBlock = { error in 184 | recordChanged.send(completion: .finished) 185 | recordZoneChangeTokensUpdated.send(completion: .finished) 186 | recordWithIDWasDeleted.send(completion: .finished) 187 | recordZoneFetchCompletion.send(completion: .finished) 188 | 189 | if let error = error { 190 | fetchRecordZoneChangesCompletion.send(completion: .failure(error)) 191 | } else { 192 | fetchRecordZoneChangesCompletion.send(completion: .finished) 193 | } 194 | } 195 | 196 | return ( 197 | fetchRecordZoneChangesCompletion: fetchRecordZoneChangesCompletion.eraseToAnyPublisher(), 198 | recordChanged: recordChanged.eraseToAnyPublisher(), 199 | recordWithIDWasDeleted: recordWithIDWasDeleted.eraseToAnyPublisher(), 200 | recordZoneChangeTokensUpdated: recordZoneChangeTokensUpdated.eraseToAnyPublisher(), 201 | recordZoneFetchCompletion: recordZoneFetchCompletion.eraseToAnyPublisher() 202 | ) 203 | } 204 | } 205 | 206 | extension CKContainer { 207 | func status(forApplicationPermission applicationPermission: CKContainer_Application_Permissions) -> AnyPublisher { 208 | Future { promise in 209 | self.status(forApplicationPermission: applicationPermission) { status, error in 210 | if let error = error { 211 | promise(.failure(error)) 212 | } else { 213 | promise(.success(status)) 214 | } 215 | } 216 | }.eraseToAnyPublisher() 217 | } 218 | 219 | func requestApplicationPermission(applicationPermission: CKContainer_Application_Permissions) -> AnyPublisher { 220 | Future { promise in 221 | self.requestApplicationPermission(applicationPermission, completionHandler: { status, error in 222 | if let error = error { 223 | promise(.failure(error)) 224 | } else { 225 | promise(.success(status)) 226 | } 227 | }) 228 | }.eraseToAnyPublisher() 229 | } 230 | 231 | func discoverUserIdentity(withUserRecordID recordID: CKRecord.ID) -> AnyPublisher { 232 | Future { promise in 233 | self.discoverUserIdentity(withUserRecordID: recordID, completionHandler: { identity, error in 234 | switch (error, identity) { 235 | case (.some(let error), _): 236 | promise(.failure(error)) 237 | case (nil, nil): 238 | promise(.failure(BasicError.noValue)) 239 | case (nil, .some(let identity)): 240 | promise(.success(identity)) 241 | } 242 | }) 243 | }.eraseToAnyPublisher() 244 | } 245 | } 246 | 247 | // MARK: - 248 | 249 | extension CKRecord { 250 | /// A function equivalent to the CKRecord subscript to access to a record property 251 | /// - Parameter key: The string key of the property 252 | /// - Throws: If the property is not contained in the CKRecord 253 | /// - Returns: The property value which conforms to the CKRecordValueProtocol 254 | func property(_ key: String) throws -> T { 255 | guard let value = self[key] as? T else { 256 | throw CKRecord.Error.decodeFailure(for: key) 257 | } 258 | return value 259 | } 260 | 261 | enum Error: Swift.Error { 262 | case decodeFailure(for: String) 263 | } 264 | } 265 | 266 | extension CKDatabase { 267 | enum Error: Swift.Error { 268 | case missingSubscriptions 269 | } 270 | 271 | func fetchAllSubscriptions() -> AnyPublisher<[CKSubscription], Swift.Error> { 272 | let publisher = PassthroughSubject<[CKSubscription], Swift.Error>() 273 | 274 | self.fetchAllSubscriptions { subscriptions, error in 275 | if let error = error { 276 | publisher.send(completion: .failure(error)) 277 | return 278 | } 279 | 280 | guard let subscriptions = subscriptions else { 281 | publisher.send(completion: .failure(Error.missingSubscriptions)) 282 | return 283 | } 284 | 285 | publisher.send(subscriptions) 286 | publisher.send(completion: .finished) 287 | } 288 | 289 | return publisher.eraseToAnyPublisher() 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Extensions/Colors+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Colors.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 02/10/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | extension Color { 13 | enum Identifier: String { 14 | case snowbank 15 | case holidayBlue = "holiday blue" 16 | case elfGreen = "elf green" 17 | 18 | case anotherBlue = "epics/anotherBlue" 19 | case blue = "epics/blue" 20 | case emeraldGreen = "epics/emeraldGreen" 21 | case gray = "epics/gray" 22 | case grayBlue = "epics/grayBlue" 23 | case green = "epics/green" 24 | case maroon = "epics/maroon" 25 | case orange = "epics/orange" 26 | case peach = "epics/peach" 27 | case pink = "epics/pink" 28 | case purple = "epics/purple" 29 | case red = "epics/red" 30 | case seaBlue = "epics/seaBlue" 31 | case yellow = "epics/yellow" 32 | 33 | case systemRed 34 | case systemGreen 35 | case systemBlue 36 | case systemOrange 37 | case systemYellow 38 | case systemBrown 39 | case systemPink 40 | case systemPurple 41 | case systemTeal 42 | case systemIndigo 43 | 44 | var system: Color? { 45 | switch self { 46 | case .systemRed: 47 | return Color.red 48 | case .systemGreen: 49 | return Color.green 50 | case .systemBlue: 51 | return Color.blue 52 | case .systemOrange: 53 | return Color.orange 54 | case .systemYellow: 55 | return Color.yellow 56 | case .systemBrown: 57 | return Color(NSColor.systemBrown) 58 | case .systemPink: 59 | return Color.pink 60 | case .systemPurple: 61 | return Color.purple 62 | case .systemTeal: 63 | return Color(NSColor.systemTeal) 64 | case .systemIndigo: 65 | return Color(NSColor.systemIndigo) 66 | default: 67 | return nil 68 | } 69 | } 70 | } 71 | 72 | init(identifier: Color.Identifier) { 73 | if let systemColor = identifier.system { 74 | self = systemColor 75 | } else { 76 | self.init(identifier.rawValue) 77 | } 78 | } 79 | } 80 | 81 | extension Color.Identifier: CaseIterable { } 82 | 83 | extension Color.Identifier { 84 | static func randomElement() -> Self { 85 | return Self.allCases.randomElement()! 86 | } 87 | 88 | static var sprintColors: [Self] { [.holidayBlue, .elfGreen] } 89 | 90 | static var epicColors: [Self] { 91 | [ 92 | systemGreen, 93 | systemBlue, 94 | systemOrange, 95 | systemYellow, 96 | systemBrown, 97 | systemPink, 98 | systemPurple, 99 | systemTeal, 100 | systemIndigo, 101 | systemRed, 102 | 103 | anotherBlue, 104 | blue, 105 | emeraldGreen, 106 | gray, 107 | grayBlue, 108 | green, 109 | maroon, 110 | orange, 111 | peach, 112 | pink, 113 | purple, 114 | red, 115 | seaBlue, 116 | yellow 117 | ] 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Extensions/Combine+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Combine+Extensions.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 20/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | extension Publisher { 13 | /// A combine operator to ignore all the next values when an error is catched 14 | /// - Parameter completion: The action to perform when an error is catched 15 | /// - Returns: The modified publisher 16 | func catchAndExit(_ completion: @escaping (Self.Failure) -> Void) -> AnyPublisher { 17 | return self 18 | .map { output -> Self.Output? in output } 19 | .catch { err -> Just in 20 | completion(err) 21 | return Just(nil) 22 | } 23 | .filter { output in 24 | output != nil 25 | } 26 | .map { output in output! } 27 | .eraseToAnyPublisher() 28 | } 29 | 30 | func sink(receiveFailure: @escaping (Failure) -> Void, receiveValue: @escaping (Self.Output) -> Void) -> AnyCancellable { 31 | return sink(receiveCompletion: { result in 32 | switch result { 33 | case .failure(let error): 34 | receiveFailure(error) 35 | case .finished: 36 | break 37 | } 38 | }, receiveValue: receiveValue) 39 | } 40 | } 41 | 42 | extension Publisher where Self.Failure == Never { 43 | /// A combine assign operator version to prevent strong reference 44 | /// - Parameters: 45 | /// - keyPath: A key path that indicates the property to assign 46 | /// - object: A key path that indicates the property to assign 47 | func assign( 48 | to keyPath: ReferenceWritableKeyPath, 49 | onStrong object: Root) -> AnyCancellable { 50 | sink { [weak object] value in 51 | object?[keyPath: keyPath] = value 52 | } 53 | } 54 | 55 | /// A combine assign operator version to prevent from strong reference, optional output version 56 | /// - Parameters: 57 | /// - keyPath: A key path that indicates the property to assign 58 | /// - object: The object that contains the property 59 | func assign( 60 | to keyPath: ReferenceWritableKeyPath, 61 | onStrong object: Root) -> AnyCancellable { 62 | sink { [weak object] value in 63 | object?[keyPath: keyPath] = value 64 | } 65 | } 66 | 67 | /// A combine operator to append the output value of a publisher to an Array of the same Output type 68 | /// The operator prevents from strong reference 69 | /// - Parameters: 70 | /// - keyPath: A key path that indicates the property to append 71 | /// - object: The object that contains the array property 72 | func append( 73 | to keyPath: ReferenceWritableKeyPath, 74 | onStrong object: Root) -> AnyCancellable { 75 | sink { [weak object] value in 76 | object?[keyPath: keyPath].append(value) 77 | } 78 | } 79 | 80 | /// A combine operator to append the output value of a publisher to an Optional Array of the same Output type 81 | /// The operator prevents from strong reference 82 | /// - Parameters: 83 | /// - keyPath: A key path that indicates the property to append 84 | /// - object: The object that contains the array property 85 | func append(to keyPath: ReferenceWritableKeyPath, onStrong object: Root) -> AnyCancellable { 86 | sink { [weak object] value in 87 | if object?[keyPath: keyPath] != nil { 88 | object?[keyPath: keyPath]?.append(value) 89 | } else { 90 | object?[keyPath: keyPath] = [value] 91 | } 92 | } 93 | } 94 | } 95 | 96 | extension Publisher where Self.Output == Never { 97 | /// A combine sink version to only receive the completion type when there is no output value 98 | /// - Parameter receiveFailure: The action to perform in case of failure completion type 99 | func sink(receiveFailure: @escaping (Failure) -> Void) -> AnyCancellable { 100 | self.sink(receiveCompletion: { completion in 101 | switch completion { 102 | case .failure(let failure): 103 | receiveFailure(failure) 104 | case .finished: 105 | break 106 | } 107 | }) 108 | } 109 | 110 | /// A combine sink version to ignore receiveValue completion block when Self.Output == Never 111 | /// - Parameter completion: The action to perform with the completion of the publisher 112 | func sink(receiveCompletion completion: @escaping (Subscribers.Completion) -> Void) -> AnyCancellable { 113 | self.sink(receiveCompletion: completion, receiveValue: { _ in }) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Extensions/Image+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Image+Extensions.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 05/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | extension Image { 12 | init(nsImageName: NSImage.Name) { 13 | self.init(nsImage: NSImage(named: nsImageName)!) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Extensions/Sequence+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sequence+Extensions.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 07/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Array { 12 | /// Useful for Binding when waiting for new element to come, but want to create one, so no get to provide 13 | /// 14 | /// This variable works only with Bindings that are nil first, then filled when completes and want to add directly the value to the array 15 | var appendedElement: Element? { 16 | get { 17 | return nil 18 | } 19 | 20 | set { 21 | if let realNewValue = newValue { 22 | self.append(realNewValue) 23 | } 24 | } 25 | } 26 | } 27 | 28 | extension Dictionary { 29 | func combine(with other: [Self.Key: OtherElement], mergingHandler: (Self.Value?, OtherElement?) -> FinalElement) -> [Self.Key: FinalElement] { 30 | var combined = [Key: FinalElement]() 31 | 32 | for (key, value) in self { 33 | combined[key] = mergingHandler(value, other[key]) 34 | } 35 | 36 | let toAdd = other.filter { !combined.keys.contains($0.key) } 37 | 38 | for (key, value) in toAdd { 39 | combined[key] = mergingHandler(nil, value) 40 | } 41 | 42 | return combined 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /RunningOrder/Shared/MainView+Logic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainView+Logic.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 19/02/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | extension MainView { 13 | final class Logic: ObservableObject { 14 | private var cancellables = Set() 15 | private unowned var spaceManager: SpaceManager 16 | 17 | var createdSpaceBinding: Binding { Binding(callback: self.addSpace(_:)) } 18 | 19 | init(spaceManager: SpaceManager) { 20 | self.spaceManager = spaceManager 21 | } 22 | 23 | private func addSpace(_ space: Space) { 24 | spaceManager.create(space: space) 25 | .ignoreOutput() 26 | .sink(receiveFailure: { failure in 27 | Logger.error.log(failure) // TODO error Handling 28 | }) 29 | .store(in: &cancellables) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /RunningOrder/Shared/MainView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainView.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 23/06/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | extension MainView { 12 | private struct InternalView: View { 13 | @EnvironmentObject var spaceManager: SpaceManager 14 | @ObservedObject var logic: Logic 15 | 16 | init(logic: Logic) { 17 | self.logic = logic 18 | } 19 | 20 | @ViewBuilder var body: some View { 21 | switch spaceManager.state { 22 | case .loading: 23 | ProgressIndicator() 24 | .padding() 25 | .frame( 26 | maxWidth: .infinity, 27 | maxHeight: .infinity, 28 | alignment: .center 29 | ) 30 | case .error(let error): 31 | Text("error : \(error)" as String) 32 | .padding() 33 | 34 | case .noSpace: 35 | WelcomeView(space: logic.createdSpaceBinding) 36 | .frame( 37 | minWidth: 300, 38 | maxWidth: 500, 39 | minHeight: 200, 40 | maxHeight: 200, 41 | alignment: .leading 42 | ) 43 | 44 | case .spaceFound(let space): 45 | NavigationView { 46 | SprintList(space: space) 47 | .listStyle(SidebarListStyle()) 48 | .frame(minWidth: 160) 49 | 50 | Text("Select a Sprint") 51 | .frame(minWidth: 100, maxWidth: 400) 52 | .toolbar { 53 | ToolbarItems.sidebarItem 54 | ToolbarItem(placement: ToolbarItemPlacement.cancellationAction) { 55 | Button(action: {}) { 56 | Image(systemName: "square.and.pencil") 57 | } 58 | .disabled(true) 59 | } 60 | } 61 | 62 | Text("Select a Story") 63 | .frame(maxWidth: .infinity, maxHeight: .infinity) 64 | .toolbar { 65 | ToolbarItem { 66 | Spacer() 67 | } 68 | 69 | ToolbarItem(placement: ToolbarItemPlacement.cancellationAction) { 70 | SearchBarView().frame(width: 300) 71 | } 72 | ToolbarItem(placement: ToolbarItemPlacement.cancellationAction) { 73 | Button(action: {}) { 74 | Image(systemName: "trash") 75 | } 76 | .disabled(true) 77 | } 78 | } 79 | } 80 | .frame( 81 | minWidth: 800, 82 | maxWidth: .infinity, 83 | minHeight: 400, 84 | maxHeight: .infinity, 85 | alignment: .leading 86 | ) 87 | } 88 | } 89 | } 90 | } 91 | 92 | struct MainView: View { 93 | @EnvironmentObject var spaceManager: SpaceManager 94 | 95 | var body: some View { 96 | InternalView(logic: Logic(spaceManager: spaceManager)) 97 | } 98 | } 99 | 100 | struct MainView_Previews: PreviewProvider { 101 | static var previews: some View { 102 | MainView() 103 | .environmentObject(SpaceManager.preview) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Protocol/CKRecordable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CKRecordable.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 20/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CloudKit 11 | 12 | /// A protocol which have to be conformed by entities stored in CloudKit to help encoding and decoding from CKRecord type 13 | protocol CKRecordable { 14 | init(from record: CKRecord) throws 15 | func encode(zoneId: CKRecordZone.ID) throws -> CKRecord 16 | } 17 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Protocol/TextfieldEditingStringHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextfieldEditingStringHandler.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 12/03/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol TextfieldEditingStringHandler: class { 12 | func fieldEditingChanged(valueKeyPath: ReferenceWritableKeyPath) -> (Bool) -> Void 13 | } 14 | 15 | extension TextfieldEditingStringHandler { 16 | private func trimValue(valueKeyPath: ReferenceWritableKeyPath) { 17 | self[keyPath: valueKeyPath] = self[keyPath: valueKeyPath].trimmingCharacters(in: .whitespacesAndNewlines) 18 | } 19 | 20 | func fieldEditingChanged(valueKeyPath: ReferenceWritableKeyPath) -> (Bool) -> Void { 21 | return { isBeginning in 22 | if !isBeginning { 23 | self.trimValue(valueKeyPath: valueKeyPath) 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Representables/FocusableNSTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FocusableNSTextField.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 23/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | import AppKit 9 | import SwiftUI 10 | 11 | /// The NSViewRepresentable of an NSFocusableTextField 12 | struct FocusableTextField: NSViewRepresentable { 13 | 14 | let placeholder: String 15 | 16 | @Binding var value: String 17 | @Binding var isFocused: Bool 18 | 19 | let onCommit: () -> Void 20 | 21 | func makeNSView(context: Context) -> NSTextField { 22 | let textField = FocusableNSTextField() 23 | textField.delegate = context.coordinator 24 | textField.placeholderString = placeholder 25 | textField.backgroundColor = NSColor.clear 26 | textField.focusRingType = .none 27 | textField.isBordered = false 28 | textField.onFocusChange = { isFocused in 29 | self.isFocused = isFocused 30 | } 31 | return textField 32 | } 33 | 34 | func updateNSView(_ nsView: NSTextField, context: Context) { 35 | nsView.stringValue = value 36 | } 37 | 38 | func makeCoordinator() -> FocusableTextField.Coordinator { 39 | Coordinator(self) 40 | } 41 | 42 | class Coordinator: NSObject, NSTextFieldDelegate { 43 | var parent: FocusableTextField 44 | 45 | init(_ textFieldContainer: FocusableTextField) { 46 | self.parent = textFieldContainer 47 | } 48 | 49 | func controlTextDidChange(_ obj: Notification) { 50 | guard let textField = obj.object as? NSTextField else { return } 51 | self.parent.value = textField.stringValue 52 | } 53 | 54 | func controlTextDidEndEditing(_ obj: Notification) { 55 | self.parent.isFocused = false 56 | self.parent.onCommit() 57 | } 58 | } 59 | } 60 | 61 | extension FocusableTextField { 62 | /// A focusable version of a NSTextField 63 | class FocusableNSTextField: NSTextField { 64 | var onFocusChange: (Bool) -> Void = { _ in } 65 | 66 | override func becomeFirstResponder() -> Bool { 67 | let textView = window?.fieldEditor(true, for: nil) as? NSTextView 68 | textView?.insertionPointColor = NSColor.controlAccentColor 69 | onFocusChange(true) 70 | return super.becomeFirstResponder() 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Representables/ProgressIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProgressIndicator.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 25/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// A NSViewRepresentable of the spinning styled NSProgressIndicator 12 | struct ProgressIndicator: NSViewRepresentable { 13 | 14 | func makeNSView(context: NSViewRepresentableContext) -> NSProgressIndicator { 15 | NSProgressIndicator() 16 | } 17 | 18 | func updateNSView(_ nsView: NSProgressIndicator, context: NSViewRepresentableContext) { 19 | nsView.style = .spinning 20 | nsView.startAnimation(true) 21 | } 22 | } 23 | 24 | struct ProgressIndicator_Previews: PreviewProvider { 25 | static var previews: some View { 26 | ProgressIndicator() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /RunningOrder/Shared/RunningOrderApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RunningOrderApp.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 02/02/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | let changesService = CloudKitChangesService(container: CloudKitContainer.shared) 12 | 13 | @main 14 | struct RunningOrderApp: App { 15 | // Not adapted to SwiftUI Lifecycle 16 | // swiftlint:disable:next weak_delegate 17 | @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate 18 | @StateObject var searchManager = SearchManager() 19 | 20 | @StateObject var spaceManager = SpaceManager( 21 | service: SpaceService(), 22 | dataPublisher: changesService.spaceChangesPublisher.eraseToAnyPublisher() 23 | ) 24 | 25 | @StateObject var sprintManager = SprintManager( 26 | service: SprintService(), 27 | dataPublisher: changesService.sprintChangesPublisher.eraseToAnyPublisher() 28 | ) 29 | 30 | @StateObject var storyManager = StoryManager( 31 | service: StoryService(), 32 | userService: UserService(), 33 | dataPublisher: changesService.storyChangesPublisher.eraseToAnyPublisher() 34 | ) 35 | 36 | @StateObject var storyInformationManager = StoryInformationManager( 37 | service: StoryInformationService(), 38 | dataPublisher: changesService.storyInformationChangesPublisher.eraseToAnyPublisher() 39 | ) 40 | 41 | var body: some Scene { 42 | WindowGroup { 43 | MainView() 44 | .environmentObject(spaceManager) 45 | .environmentObject(sprintManager) 46 | .environmentObject(storyManager) 47 | .environmentObject(storyInformationManager) 48 | .environmentObject(searchManager) 49 | .onAppear { 50 | appDelegate.changesService = changesService 51 | appDelegate.spaceManager = spaceManager 52 | changesService.fetchChanges() 53 | } 54 | } 55 | .commands { 56 | CommandGroup(replacing: CommandGroupPlacement.newItem) { 57 | Button(action: CloudSharingHandler(spaceManager: spaceManager).performCloudSharing, label: { 58 | Label("Partager l'espace de travail", systemImage: "person.crop.circle.badge.plus") 59 | }) 60 | } 61 | ToolbarCommands() 62 | SidebarCommands() 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /RunningOrder/Shared/ToolbarItems.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolbarItems.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 02/02/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | enum ToolbarItems { 12 | static let sidebarItem = ToolbarItem(placement: ToolbarItemPlacement.navigation) { 13 | Button { 14 | NSApp.keyWindow? 15 | .firstResponder? 16 | .tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil) 17 | } label: { 18 | Image(systemName: "sidebar.left") 19 | } 20 | } 21 | 22 | static func cloudSharingItem(for cloudSharingManager: CloudSharingHandler) -> some ToolbarContent { 23 | ToolbarItem(placement: ToolbarItemPlacement.confirmationAction) { 24 | Button(action: cloudSharingManager.performCloudSharing, label: { 25 | Image(systemName: "person.crop.circle.badge.plus") 26 | }) 27 | } 28 | } 29 | 30 | static func deleteStory(storyManager: StoryManager, story: Story) -> some ToolbarContent { 31 | ToolbarItem(placement: ToolbarItemPlacement.cancellationAction) { 32 | Button(action: { storyManager.delete(story: story) }) { 33 | Image(systemName: "trash") 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Utils/BasicError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BasicError.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 12/03/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum BasicError: Error { 12 | case noValue 13 | } 14 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Utils/CloudKitChangesService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudKitChangesService.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 05/10/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import CloudKit 12 | 13 | typealias ChangeInformation = (toUpdate: [CKRecord], toDelete: [CKRecord.ID]) 14 | 15 | final class CloudKitChangesService: ObservableObject { 16 | private unowned let container: CloudKitContainer 17 | private var currentChangeServerToken: CKServerChangeToken? 18 | 19 | private var cancellables = Set() 20 | 21 | let sprintChangesPublisher = PassthroughSubject() 22 | let storyChangesPublisher = PassthroughSubject() 23 | let storyInformationChangesPublisher = PassthroughSubject() 24 | let spaceChangesPublisher = PassthroughSubject() 25 | 26 | init(container: CloudKitContainer) { 27 | self.container = container 28 | } 29 | 30 | func fetchChanges() { 31 | let zoneId = container.sharedZoneId 32 | let operation = CKFetchRecordZoneChangesOperation( 33 | recordZoneIDs: [zoneId], 34 | configurationsByRecordZoneID: [zoneId: .init(previousServerChangeToken: currentChangeServerToken)] 35 | ) 36 | 37 | operation.qualityOfService = .userInteractive 38 | 39 | operation.fetchAllChanges = true 40 | 41 | let (_, recordPublisher, recordDeletedPublisher, tokenChangesPublisher, recordZoneFetchPublisher) = operation.publishers() 42 | 43 | Publishers.CombineLatest(recordPublisher.collect(), recordDeletedPublisher.collect()) 44 | .map { records, toDelete -> ([CKRecord.RecordType: ChangeInformation]) in 45 | let idsToDelete = toDelete.map { $0.recordId } 46 | let toUpdate = records.filter { !idsToDelete.contains($0.recordID) } 47 | 48 | let groupedUpdates = [CKRecord.RecordType: [CKRecord]](grouping: toUpdate, by: { record in record.recordType }) 49 | let groupedDeletion = [CKRecord.RecordType: [(recordId: CKRecord.ID, recordType: CKRecord.RecordType)]](grouping: toDelete, by: { element in return element.recordType }) 50 | .mapValues { array -> [CKRecord.ID] in return array.map { $0.recordId } } 51 | 52 | return groupedUpdates.combine(with: groupedDeletion) { updates, deletions -> ChangeInformation in 53 | (updates ?? [], deletions ?? []) 54 | } 55 | } 56 | .receive(on: DispatchQueue.main) 57 | .sink(receiveCompletion: { completion in 58 | switch completion { 59 | case .finished: 60 | Logger.debug.log("call finished") 61 | if self.firstCallSpaceEmpty { 62 | self.spaceChangesPublisher.send((toUpdate: [], toDelete: [])) 63 | self.firstCallSpaceEmpty = false 64 | } 65 | 66 | case .failure(let error): 67 | Logger.error.log("error : \(error)") // TODO: Error handling 68 | } 69 | }, receiveValue: { [weak self] updates in self?.handleUpdates(updates: updates) }) 70 | .store(in: &cancellables) 71 | 72 | let tokenChanged = tokenChangesPublisher 73 | .filter { $0.zoneId == zoneId } 74 | .map(\.serverToken) 75 | 76 | recordZoneFetchPublisher 77 | .filter { $0.zoneId == zoneId } 78 | .map(\.serverToken) 79 | .catchAndExit { [weak self] error in 80 | if let error = error as? CKError, error.code == .changeTokenExpired { 81 | self?.currentChangeServerToken = nil 82 | self?.fetchChanges() 83 | } 84 | Logger.error.log(error) // TODO: Error handling 85 | } 86 | .merge(with: tokenChanged) 87 | .assign(to: \.currentChangeServerToken, onStrong: self) 88 | .store(in: &cancellables) 89 | 90 | Logger.debug.log("launch operation") 91 | container.currentDatabase.add(operation) 92 | } 93 | 94 | private var firstCallSpaceEmpty = true 95 | 96 | func handleUpdates(updates: [CKRecord.RecordType: ChangeInformation]) { 97 | for (key, changes) in updates { 98 | guard let type = RecordType(rawValue: key) else { 99 | if key != "cloudkit.share" { 100 | Logger.error.log("unrecognized record type \(key)") 101 | } 102 | continue 103 | } 104 | 105 | switch type { 106 | case .sprint: 107 | self.sprintChangesPublisher.send(changes) 108 | case .story: 109 | self.storyChangesPublisher.send(changes) 110 | case .storyInformation: 111 | self.storyInformationChangesPublisher.send(changes) 112 | case .space: 113 | self.spaceChangesPublisher.send(changes) 114 | self.firstCallSpaceEmpty = false 115 | } 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Utils/CloudKitContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudkitContainer.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 26/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CloudKit 11 | import Combine 12 | 13 | extension CloudKitContainer { 14 | enum Mode { 15 | case owner 16 | case shared(ownerName: String) 17 | 18 | var isOwner: Bool { 19 | switch self { 20 | case .shared: 21 | return false 22 | case .owner: 23 | return true 24 | } 25 | } 26 | } 27 | } 28 | 29 | class CloudKitContainer { 30 | private static let createdCustomZoneKey = "CloudKitCreatedSharedZone" 31 | private static var createdCustomZone: Bool { 32 | get { return UserDefaults.standard.bool(forKey: createdCustomZoneKey) } 33 | set { UserDefaults.standard.set(newValue, forKey: createdCustomZoneKey) } 34 | } 35 | 36 | private static let sharedOwnerNameKey = "CloudKitSharedOwnerName" 37 | private static var sharedOwnerName: String? { 38 | get { return UserDefaults.standard.string(forKey: sharedOwnerNameKey) } 39 | set { UserDefaults.standard.set(newValue, forKey: sharedOwnerNameKey) } 40 | } 41 | 42 | private static let zoneName = "SharedZone" 43 | 44 | private let ownedZoneId = CKRecordZone.ID(zoneName: CloudKitContainer.zoneName, ownerName: CKCurrentUserDefaultName) 45 | 46 | private var cancellables = Set() 47 | 48 | static let shared = CloudKitContainer() // singleton 49 | 50 | let container = CKContainer(identifier: "iCloud.com.worldline.RunningOrder") 51 | 52 | var sharedZoneId: CKRecordZone.ID { 53 | switch mode { 54 | case .owner: 55 | return ownedZoneId 56 | case .shared(let ownerName): 57 | return CKRecordZone.ID(zoneName: CloudKitContainer.zoneName, ownerName: ownerName) 58 | } 59 | } 60 | 61 | var currentDatabase: CKDatabase { 62 | switch mode { 63 | case .owner: 64 | return container.privateCloudDatabase 65 | case .shared: 66 | return container.sharedCloudDatabase 67 | } 68 | } 69 | 70 | var mode: Mode { 71 | didSet { 72 | switch mode { 73 | case .owner: 74 | Self.sharedOwnerName = nil 75 | case .shared(let ownerName): 76 | Self.sharedOwnerName = ownerName 77 | } 78 | 79 | if mode.isOwner != oldValue.isOwner { 80 | enableNotificationsIfNeeded() 81 | askPermissionForDiscoverabilityIfNeeded() 82 | } 83 | } 84 | } 85 | 86 | // MARK: - 87 | 88 | init() { 89 | if let sharedOwnerName = CloudKitContainer.sharedOwnerName { 90 | mode = .shared(ownerName: sharedOwnerName) 91 | } else { 92 | mode = .owner 93 | } 94 | 95 | createCustomZoneIfNeeded() 96 | enableNotificationsIfNeeded() 97 | askPermissionForDiscoverabilityIfNeeded() 98 | } 99 | 100 | private func createCustomZoneIfNeeded() { 101 | guard mode.isOwner && !CloudKitContainer.createdCustomZone else { return } 102 | 103 | Logger.verbose.log("shared zone creation") 104 | let sharedZone = CKRecordZone(zoneID: ownedZoneId) 105 | let zoneOperation = CKModifyRecordZonesOperation() 106 | zoneOperation.recordZonesToSave = [sharedZone] 107 | 108 | zoneOperation.modifyRecordZonesCompletionBlock = { _, _, error in 109 | if let error = error { 110 | Logger.error.log("error while creating custom zone : \(error)") 111 | } else { 112 | CloudKitContainer.createdCustomZone = true 113 | } 114 | } 115 | container.privateCloudDatabase.add(zoneOperation) 116 | } 117 | 118 | private func subscriptionId(for database: CKDatabase) -> CKSubscription.ID { 119 | switch database.databaseScope { 120 | case .private: 121 | return "privateDBSubscription" 122 | case .public: 123 | return "publicDBSubscription" 124 | case .shared: 125 | return "sharedDBSubscription" 126 | @unknown default: 127 | fatalError("unknown case \(database.databaseScope)") 128 | } 129 | } 130 | 131 | private func createSubscriptions(for database: CKDatabase) -> AnyPublisher { 132 | let subscription = CKDatabaseSubscription(subscriptionID: subscriptionId(for: database)) 133 | let notificationInfo = CKSubscription.NotificationInfo() 134 | notificationInfo.shouldSendContentAvailable = true 135 | subscription.notificationInfo = notificationInfo 136 | 137 | let operation = CKModifySubscriptionsOperation( 138 | subscriptionsToSave: [subscription], 139 | subscriptionIDsToDelete: [] 140 | ) 141 | 142 | operation.qualityOfService = .utility 143 | 144 | database.add(operation) 145 | 146 | return operation.publisher() 147 | .ignoreOutput() 148 | .eraseToAnyPublisher() 149 | } 150 | 151 | // MARK: - 152 | 153 | func resetModeIfNeeded() { 154 | if !self.mode.isOwner { 155 | self.mode = .owner 156 | } 157 | } 158 | 159 | func enableNotificationsIfNeeded() { 160 | let database = currentDatabase 161 | 162 | return database.fetchAllSubscriptions() 163 | .filter { $0.isEmpty } 164 | .flatMap { [weak self] _ in return self?.createSubscriptions(for: database) ?? Empty(completeImmediately: true, outputType: Never.self, failureType: Error.self).eraseToAnyPublisher() } 165 | .sink(receiveFailure: { error in Logger.error.log(error) }) 166 | .store(in: &cancellables) 167 | } 168 | 169 | func askPermissionForDiscoverabilityIfNeeded() { 170 | container.status(forApplicationPermission: .userDiscoverability) 171 | .filter { $0 == .initialState } 172 | .flatMap { _ in self.container.requestApplicationPermission(applicationPermission: .userDiscoverability) } 173 | .sink(receiveFailure: { error in 174 | Logger.error.log("error at requesting permission : \(error)") 175 | }, receiveValue: { status in 176 | switch status { 177 | case .couldNotComplete: 178 | Logger.error.log("error when requesting permission for discoverability") 179 | case .granted: 180 | Logger.debug.log("Discoverability granted") 181 | case .denied: 182 | Logger.debug.log("Discoverability denied :'(") 183 | case .initialState: 184 | fallthrough 185 | @unknown default: 186 | break 187 | } 188 | }) 189 | .store(in: &cancellables) 190 | } 191 | 192 | func validateNotification(_ userInfo: [String: Any]) -> Bool { 193 | guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) else { return false } 194 | 195 | return notification.subscriptionID == subscriptionId(for: currentDatabase) 196 | } 197 | } 198 | 199 | enum RecordType: String { 200 | case sprint = "Sprint" 201 | case story = "Story" 202 | case storyInformation = "StoryInformation" 203 | case space = "Space" 204 | } 205 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Utils/Environment+EpicColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EpicColor.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 12/03/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | private struct EpicColorKey: EnvironmentKey { 12 | static let defaultValue: Color = Color(identifier: .holidayBlue) 13 | } 14 | 15 | extension EnvironmentValues { 16 | var epicColor: Color { 17 | get { self[EpicColorKey.self] } 18 | set { self[EpicColorKey.self] = newValue } 19 | } 20 | } 21 | 22 | extension View { 23 | func epicColor(_ myCustomValue: Color) -> some View { 24 | environment(\.epicColor, myCustomValue) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /RunningOrder/Shared/Utils/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 23/09/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum Logger { 12 | case verbose 13 | case debug 14 | case error 15 | case warning 16 | 17 | private var icon: String { 18 | switch self { 19 | case .debug: 20 | return "🟣" 21 | case .verbose: 22 | return "🟡" 23 | case .warning: 24 | return "🟠" 25 | case .error: 26 | return "🔴" 27 | } 28 | } 29 | 30 | func log(_ value: Any, file: String = #file, line: Int = #line, function: String = #function) { 31 | print("\(self.icon) \(file):\(line) \(function) - \(value)") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /RunningOrder/Space/Space.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Space.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 21/09/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CloudKit 11 | 12 | /// A work space, referenced from all sprints. 13 | /// What we share between users is a space, with all data hierarchy beneath it. 14 | struct Space { 15 | var id: ID { underlyingRecord.recordID.recordName } 16 | var name: String { 17 | do { 18 | return try underlyingRecord.property("name") 19 | } catch { 20 | #if DEBUG 21 | fatalError("\(error)") 22 | #else 23 | Logger.error.log(error) 24 | return "" 25 | #endif 26 | } 27 | } 28 | 29 | private(set) var underlyingRecord: CKRecord 30 | } 31 | 32 | extension Space { 33 | // swiftlint:disable:next type_name 34 | typealias ID = String 35 | } 36 | 37 | extension Space { 38 | init(name: String) { 39 | let id = CKRecord.ID(recordName: UUID().uuidString, zoneID: CloudKitContainer.shared.sharedZoneId) 40 | let record = CKRecord(recordType: RecordType.space.rawValue, recordID: id) 41 | record["name"] = name 42 | self.underlyingRecord = record 43 | } 44 | 45 | var isShared: Bool { self.underlyingRecord.share != nil } 46 | } 47 | -------------------------------------------------------------------------------- /RunningOrder/Space/SpaceManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpaceManager.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 22/09/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import CloudKit 12 | 13 | extension SpaceManager { 14 | enum State { 15 | case loading 16 | case error(Swift.Error) 17 | case noSpace 18 | case spaceFound(Space) 19 | } 20 | 21 | enum Error: Swift.Error { 22 | case noSpaceAvailable 23 | } 24 | } 25 | 26 | final class SpaceManager: ObservableObject { 27 | let spaceService: SpaceService 28 | 29 | var cancellables = Set() 30 | 31 | @Published var state: State = .loading 32 | 33 | var space: Space? { 34 | guard case State.spaceFound(let space) = state else { return nil } 35 | 36 | return space 37 | } 38 | 39 | init(service: SpaceService, dataPublisher: AnyPublisher) { 40 | self.spaceService = service 41 | 42 | dataPublisher 43 | .map(updateState(with:)) 44 | .assign(to: \.state, onStrong: self) 45 | .store(in: &cancellables) 46 | } 47 | 48 | private func updateState(with information: ChangeInformation) -> State { 49 | Logger.debug.log(information) 50 | if !information.toDelete.isEmpty, case .spaceFound(let space) = state, information.toDelete.contains(where: { $0.recordName == space.id }) { 51 | // current space will be deleted 52 | spaceService.delete(space: space) 53 | .receive(on: DispatchQueue.main) 54 | .sink(receiveCompletion: { completion in 55 | switch completion { 56 | case .failure(let error): 57 | Logger.error.log(error) // TODO: error handling 58 | case .finished: 59 | self.state = .noSpace 60 | } 61 | }) 62 | .store(in: &cancellables) 63 | } 64 | 65 | if let record = information.toUpdate.last { 66 | return .spaceFound(Space(underlyingRecord: record)) 67 | } else { 68 | return .noSpace 69 | } 70 | } 71 | 72 | func fetchFromShared(_ recordId: CKRecord.ID) { 73 | Logger.verbose.log("try to fetch from shared") 74 | spaceService.fetchShared(recordId) 75 | .map { State.spaceFound($0) } 76 | .catch { Just(State.error($0)) } 77 | .replaceEmpty(with: State.error(Error.noSpaceAvailable)) 78 | .receive(on: DispatchQueue.main) 79 | .assign(to: \.state, onStrong: self) 80 | .store(in: &cancellables) 81 | } 82 | 83 | func create(space: Space) -> AnyPublisher { 84 | let spaceResult = spaceService.save(space: space).share().receive(on: DispatchQueue.main) 85 | 86 | spaceResult 87 | .map { State.spaceFound($0) } 88 | .catch { Just(State.error($0)) } 89 | .assign(to: \.state, onStrong: self) 90 | .store(in: &cancellables) 91 | 92 | return spaceResult.eraseToAnyPublisher() 93 | } 94 | 95 | func deleteCurrentSpace() { 96 | guard case SpaceManager.State.spaceFound(let space) = self.state else { return } 97 | 98 | spaceService.delete(space: space) 99 | .receive(on: DispatchQueue.main) 100 | .sink(receiveCompletion: { result in 101 | switch result { 102 | case .failure(let error): 103 | Logger.error.log(error) 104 | case .finished: 105 | self.state = .noSpace 106 | } 107 | }) 108 | .store(in: &cancellables) 109 | } 110 | 111 | func saveAndShare() -> AnyPublisher { 112 | guard let space = self.space else { 113 | return Fail(outputType: CKShare.self, failure: Error.noSpaceAvailable).eraseToAnyPublisher() 114 | } 115 | 116 | return self.spaceService.saveAndShare(space: space) 117 | } 118 | 119 | func getShare() -> AnyPublisher { 120 | guard let space = self.space else { 121 | return Fail(outputType: CKShare.self, failure: Error.noSpaceAvailable).eraseToAnyPublisher() 122 | } 123 | return self.spaceService.getShare(for: space) 124 | } 125 | 126 | func acceptShare(metadata: CKShare.Metadata) { 127 | self.spaceService.acceptShare(metadata: metadata) 128 | .sink( 129 | receiveFailure: { error in Logger.error.log("error : \(error)") }, 130 | receiveValue: { [weak self] updatedMetadata in self?.fetchFromShared(updatedMetadata.rootRecordID) }) 131 | .store(in: &cancellables) 132 | } 133 | } 134 | 135 | extension SpaceManager { 136 | static let preview = SpaceManager(service: SpaceService(), dataPublisher: Empty().eraseToAnyPublisher()) 137 | } 138 | -------------------------------------------------------------------------------- /RunningOrder/Space/SpaceService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpaceService.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 21/09/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import CloudKit 12 | 13 | extension SpaceService { 14 | enum Error: Swift.Error { 15 | case noShareFound 16 | } 17 | } 18 | 19 | /// The service responsible of all the Sprint CRUD operation 20 | class SpaceService { 21 | let cloudkitContainer = CloudKitContainer.shared 22 | var cancellables = Set() 23 | 24 | func fetchShared(_ id: CKRecord.ID) -> AnyPublisher { 25 | let operation = CKFetchRecordsOperation(recordIDs: [id]) 26 | cloudkitContainer.container.sharedCloudDatabase.add(operation) 27 | 28 | return operation.publishers() 29 | .perRecord 30 | .tryMap { result -> Space in 31 | if let record = result.0 { 32 | return Space(underlyingRecord: record) 33 | } else { 34 | throw SpaceManager.Error.noSpaceAvailable 35 | } 36 | }.eraseToAnyPublisher() 37 | } 38 | 39 | func save(space: Space) -> AnyPublisher { 40 | let saveOperation = CKModifyRecordsOperation() 41 | saveOperation.recordsToSave = [space.underlyingRecord] 42 | 43 | let configuration = CKOperation.Configuration() 44 | configuration.qualityOfService = .utility 45 | 46 | saveOperation.configuration = configuration 47 | 48 | cloudkitContainer.currentDatabase.add(saveOperation) 49 | 50 | return saveOperation.publishers().perRecord 51 | .tryMap { Space(underlyingRecord: $0) } 52 | .eraseToAnyPublisher() 53 | } 54 | 55 | func getShare(for space: Space) -> AnyPublisher { 56 | guard let existingShareReference = space.underlyingRecord.share else { 57 | fatalError("this call shouldn't be done without verfying the share optionality") 58 | } 59 | 60 | let operation = CKFetchRecordsOperation(recordIDs: [existingShareReference.recordID]) 61 | cloudkitContainer.currentDatabase.add(operation) 62 | return operation.publishers() 63 | .completion 64 | .compactMap { $0?[existingShareReference.recordID] as? CKShare } 65 | .eraseToAnyPublisher() 66 | } 67 | 68 | func saveAndShare(space: Space) -> AnyPublisher { 69 | let share = CKShare(rootRecord: space.underlyingRecord) 70 | share[CKShare.SystemFieldKey.title] = space.name 71 | 72 | let saveOperation = CKModifyRecordsOperation() 73 | saveOperation.recordsToSave = [space.underlyingRecord, share] 74 | 75 | let configuration = CKOperation.Configuration() 76 | configuration.qualityOfService = .utility 77 | 78 | saveOperation.configuration = configuration 79 | 80 | cloudkitContainer.currentDatabase.add(saveOperation) 81 | 82 | return saveOperation.publishers() 83 | .completion 84 | .map { _ in share } 85 | .eraseToAnyPublisher() 86 | } 87 | 88 | func delete(space: Space) -> AnyPublisher { 89 | let deleteOperation = CKModifyRecordsOperation() 90 | let recordIdToDelete: CKRecord.ID 91 | if cloudkitContainer.mode.isOwner { 92 | recordIdToDelete = space.underlyingRecord.recordID 93 | } else { 94 | if let shareId = space.underlyingRecord.share?.recordID { 95 | recordIdToDelete = shareId 96 | } else { 97 | Logger.error.log("couldn't find the id of the share this way") 98 | return Fail(error: Error.noShareFound).eraseToAnyPublisher() 99 | } 100 | 101 | } 102 | deleteOperation.recordIDsToDelete = [recordIdToDelete] 103 | 104 | let configuration = CKOperation.Configuration() 105 | configuration.qualityOfService = .utility 106 | 107 | deleteOperation.configuration = configuration 108 | 109 | cloudkitContainer.currentDatabase.add(deleteOperation) 110 | 111 | return deleteOperation.publishers() 112 | .completion 113 | .ignoreOutput() 114 | .eraseToAnyPublisher() 115 | } 116 | 117 | func acceptShare(metadata: CKShare.Metadata) -> AnyPublisher { 118 | let acceptSharesOperation = CKAcceptSharesOperation(shareMetadatas: [metadata]) 119 | 120 | let pub = acceptSharesOperation.publishers().perShare.map { return $0.0 }.share() 121 | 122 | pub.sink( 123 | receiveFailure: { _ in }, 124 | receiveValue: { [weak self] updatedMetadata in 125 | if let ownerId = updatedMetadata.ownerIdentity.userRecordID?.recordName { 126 | self?.cloudkitContainer.mode = .shared(ownerName: ownerId) 127 | } else { 128 | Logger.error.log("no owner !") 129 | } 130 | }) 131 | .store(in: &cancellables) 132 | 133 | let remoteContainer = CKContainer(identifier: metadata.containerIdentifier) 134 | 135 | remoteContainer.add(acceptSharesOperation) 136 | 137 | return pub.eraseToAnyPublisher() 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /RunningOrder/Sprint/Extensions/Services/SprintService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SprintService.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 26/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import CloudKit 12 | 13 | /// The service responsible of all the Sprint CRUD operation 14 | class SprintService { 15 | let cloudkitContainer = CloudKitContainer.shared 16 | 17 | func save(sprint: Sprint) -> AnyPublisher { 18 | let sprintRecord = sprint.encode(zoneId: cloudkitContainer.sharedZoneId) 19 | 20 | let saveOperation = CKModifyRecordsOperation() 21 | saveOperation.recordsToSave = [sprintRecord] 22 | 23 | let configuration = CKOperation.Configuration() 24 | configuration.qualityOfService = .utility 25 | 26 | saveOperation.configuration = configuration 27 | 28 | cloudkitContainer.currentDatabase.add(saveOperation) 29 | 30 | return saveOperation.publishers().perRecord 31 | .tryMap { try Sprint.init(from: $0) } 32 | .eraseToAnyPublisher() 33 | } 34 | 35 | func delete(sprint: Sprint) -> AnyPublisher { 36 | let record = sprint.encode(zoneId: cloudkitContainer.sharedZoneId) 37 | let deleteOperation = CKModifyRecordsOperation(recordsToSave: nil, recordIDsToDelete: [record.recordID]) 38 | 39 | let configuration = CKOperation.Configuration() 40 | configuration.qualityOfService = .utility 41 | 42 | deleteOperation.configuration = configuration 43 | 44 | cloudkitContainer.currentDatabase.add(deleteOperation) 45 | 46 | return deleteOperation.publishers() 47 | .completion 48 | .ignoreOutput() 49 | .eraseToAnyPublisher() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /RunningOrder/Sprint/Extensions/Sprint+CloudKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sprint+CloudKit.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 20/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CloudKit 11 | 12 | extension Sprint: CKRecordable { 13 | init(from record: CKRecord) throws { 14 | let ref: CKRecord.Reference = try record.property("spaceId") 15 | self.spaceId = ref.recordID.recordName 16 | self.name = try record.property("name") 17 | self.number = try record.property("number") 18 | self.colorIdentifier = try record.property("colorIdentifier") 19 | } 20 | 21 | func encode(zoneId: CKRecordZone.ID) -> CKRecord { 22 | let sprintRecord = CKRecord(recordType: RecordType.sprint.rawValue, recordID: recordId(zoneId: zoneId)) 23 | 24 | sprintRecord["spaceId"] = CKRecord.Reference(recordID: spaceRecordId(zoneId: zoneId), action: .deleteSelf) 25 | sprintRecord.parent = CKRecord.Reference(recordID: spaceRecordId(zoneId: zoneId), action: .none) 26 | 27 | sprintRecord["name"] = self.name 28 | sprintRecord["number"] = self.number 29 | sprintRecord["colorIdentifier"] = self.colorIdentifier 30 | 31 | return sprintRecord 32 | } 33 | 34 | private func recordId(zoneId: CKRecordZone.ID) -> CKRecord.ID { 35 | return CKRecord.ID(recordName: self.id, zoneID: zoneId) 36 | } 37 | 38 | private func spaceRecordId(zoneId: CKRecordZone.ID) -> CKRecord.ID { 39 | return CKRecord.ID(recordName: self.spaceId, zoneID: zoneId) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /RunningOrder/Sprint/Managers/SprintManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SprintManager.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 06/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | import CloudKit 12 | 13 | /// The class responsible of managing the Sprint data, this is the only source of truth 14 | final class SprintManager: ObservableObject { 15 | @Published var sprints: [Sprint] = [] 16 | 17 | var cancellables: Set = [] 18 | 19 | private let service: SprintService 20 | 21 | init(service: SprintService, dataPublisher: AnyPublisher) { 22 | self.service = service 23 | 24 | dataPublisher.sink(receiveValue: { [weak self] informations in 25 | self?.updateData(with: informations.toUpdate) 26 | self?.deleteData(recordIds: informations.toDelete) 27 | }).store(in: &cancellables) 28 | } 29 | 30 | func add(sprint: Sprint) -> AnyPublisher { 31 | let saveSprintPublisher = service.save(sprint: sprint) 32 | .share() 33 | .receive(on: DispatchQueue.main) 34 | 35 | saveSprintPublisher 36 | .catchAndExit { _ in } 37 | .append(to: \.sprints, onStrong: self) 38 | .store(in: &cancellables) 39 | 40 | return saveSprintPublisher.eraseToAnyPublisher() 41 | } 42 | 43 | func updateData(with updatedRecords: [CKRecord]) { 44 | for updatedRecord in updatedRecords { 45 | do { 46 | let sprint = try Sprint(from: updatedRecord) 47 | if let index = sprints.firstIndex(where: { $0.id == sprint.id }) { 48 | DispatchQueue.main.async { 49 | self.sprints[index] = sprint 50 | } 51 | } else { 52 | Logger.verbose.log("sprint with id \(sprint.id) not found, so appending it to existing sprint list") 53 | DispatchQueue.main.async { 54 | self.sprints.append(sprint) 55 | } 56 | } 57 | } catch { 58 | Logger.error.log(error) 59 | } 60 | } 61 | } 62 | 63 | func delete(sprint: Sprint) { 64 | guard let index = self.sprints.firstIndex(of: sprint) else { 65 | Logger.error.log("couldn't find index of sprint in stored sprints") 66 | return 67 | } 68 | 69 | service.delete(sprint: sprint) 70 | .receive(on: DispatchQueue.main) 71 | .sink(receiveCompletion: { [weak self] completion in 72 | switch completion { 73 | case .failure(let error): 74 | Logger.error.log(error) // TODO: error Handling 75 | case .finished: 76 | self?.sprints.remove(at: index) 77 | } 78 | }) 79 | .store(in: &cancellables) 80 | } 81 | 82 | func deleteData(recordIds: [CKRecord.ID]) { 83 | for recordId in recordIds { 84 | guard let index = sprints.firstIndex(where: { $0.id == recordId.recordName }) else { 85 | Logger.warning.log("sprint not found when deleting \(recordId.recordName)") 86 | return 87 | } 88 | DispatchQueue.main.async { 89 | self.sprints.remove(at: index) 90 | } 91 | } 92 | } 93 | } 94 | 95 | extension SprintManager { 96 | static let preview = SprintManager(service: SprintService(), dataPublisher: Empty().eraseToAnyPublisher()) 97 | } 98 | -------------------------------------------------------------------------------- /RunningOrder/Sprint/Models/Search.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Search.swift 3 | // RunningOrder 4 | // 5 | // Created by Ghita Laoud on 22/04/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct SearchSection { 12 | var type: SearchSection.SectionType 13 | var items: Set 14 | } 15 | 16 | extension SearchSection { 17 | enum SectionType: String { 18 | case story // Jira reference + story's name 19 | case epic // epic name 20 | case people // story's creator name 21 | 22 | var icon: String { 23 | switch self { 24 | case .story: 25 | return "list.bullet.rectangle" 26 | case .epic: 27 | return "folder.fill" 28 | case .people: 29 | return "person.circle" 30 | } 31 | } 32 | 33 | var title: String { 34 | return self.rawValue.uppercased() 35 | } 36 | } 37 | } 38 | 39 | extension SearchSection: Identifiable { 40 | var id: String { type.title } 41 | } 42 | 43 | struct SearchItem: Hashable { 44 | var name: String 45 | var icon: String 46 | var type: SearchSection.SectionType 47 | var relatedStory: Story? 48 | 49 | static func == (lhs: SearchItem, rhs: SearchItem) -> Bool { 50 | lhs.name == rhs.name 51 | } 52 | } 53 | 54 | extension SearchItem: Identifiable { 55 | var id: String { name } 56 | } 57 | -------------------------------------------------------------------------------- /RunningOrder/Sprint/Models/Sprint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sprint.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 07/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Sprint { 12 | let spaceId: Space.ID 13 | let number: Int 14 | let name: String 15 | let colorIdentifier: String 16 | } 17 | 18 | extension Sprint { 19 | // swiftlint:disable:next type_name 20 | typealias ID = String 21 | var id: ID { return "\(self.name)\(self.number)" } 22 | } 23 | 24 | extension Sprint: Equatable { } 25 | extension Sprint: Hashable { } 26 | -------------------------------------------------------------------------------- /RunningOrder/Sprint/Services/SprintService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SprintService.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 26/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import CloudKit 12 | 13 | class SprintService { 14 | let cloudkitContainer = CloudKitContainer.shared 15 | 16 | func fetchAll() -> AnyPublisher<[Sprint], Swift.Error> { 17 | 18 | // we query all the records 19 | let predicate = NSPredicate(value: true) 20 | let query = CKQuery(recordType: RecordType.sprint.rawValue, predicate: predicate) 21 | query.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)] 22 | 23 | let fetchOperation = CKQueryOperation(query: query) 24 | 25 | fetchOperation.zoneID = cloudkitContainer.sharedZoneId 26 | 27 | let configuration = CKOperation.Configuration() 28 | configuration.timeoutIntervalForRequest = 5 29 | configuration.timeoutIntervalForResource = 5 30 | 31 | fetchOperation.configuration = configuration 32 | 33 | cloudkitContainer.container.privateCloudDatabase.add(fetchOperation) 34 | 35 | return fetchOperation 36 | .recordFetchedPublisher() 37 | .tryMap { try Sprint.init(from: $0) } 38 | .collect() 39 | .eraseToAnyPublisher() 40 | } 41 | 42 | func save(sprint: Sprint) -> AnyPublisher { 43 | let sprintRecord = sprint.encode(zoneId: cloudkitContainer.sharedZoneId) 44 | 45 | let saveOperation = CKModifyRecordsOperation() 46 | saveOperation.recordsToSave = [sprintRecord] 47 | 48 | let configuration = CKOperation.Configuration() 49 | configuration.qualityOfService = .utility 50 | 51 | saveOperation.configuration = configuration 52 | 53 | cloudkitContainer.container.privateCloudDatabase.add(saveOperation) 54 | 55 | return saveOperation.perRecordPublisher() 56 | .tryMap { try Sprint.init(from: $0) } 57 | .eraseToAnyPublisher() 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /RunningOrder/Sprint/Views/NewSprintView+Logic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewSprintView+Logic.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 19/02/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | extension NewSprintView { 13 | final class Logic: ObservableObject, TextfieldEditingStringHandler { 14 | let spaceId: Space.ID 15 | @Binding var createdSprint: Sprint? 16 | 17 | @Published var name: String = "" 18 | @Published var number: Int? 19 | 20 | var dismissSubject = PassthroughSubject() 21 | 22 | var areAllFieldsFilled: Bool { number != nil && !name.isEmpty } 23 | 24 | init(spaceId: Space.ID, createdSprint: Binding) { 25 | self.spaceId = spaceId 26 | self._createdSprint = createdSprint 27 | } 28 | 29 | func createSprint() { 30 | guard areAllFieldsFilled else { return } 31 | self.createdSprint = Sprint( 32 | spaceId: spaceId, 33 | number: number!, 34 | name: name, 35 | colorIdentifier: Color.Identifier.sprintColors.randomElement()!.rawValue 36 | ) 37 | dismissSubject.send(()) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /RunningOrder/Sprint/Views/NewSprintView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewSprintView.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 07/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct NewSprintView: View { 12 | @ObservedObject var logic: Logic 13 | @Environment(\.presentationMode) var presentationMode 14 | 15 | init(spaceId: Space.ID, createdSprint: Binding) { 16 | self.logic = Logic(spaceId: spaceId, createdSprint: createdSprint) 17 | } 18 | 19 | var body: some View { 20 | VStack { 21 | TextField( 22 | "Sprint Name", 23 | text: $logic.name, 24 | onEditingChanged: logic.fieldEditingChanged(valueKeyPath: \.name), 25 | onCommit: logic.createSprint 26 | ) 27 | TextField( 28 | "Sprint Number", 29 | value: $logic.number, 30 | formatter: NumberFormatter(), 31 | onCommit: logic.createSprint 32 | ) 33 | 34 | HStack { 35 | Button(action: dismiss) { Text("Cancel") } 36 | Spacer() 37 | Button(action: logic.createSprint) { Text("Create") } 38 | .disabled(!logic.areAllFieldsFilled) 39 | } 40 | } 41 | .padding() 42 | .onReceive(logic.dismissSubject, perform: dismiss) 43 | } 44 | 45 | private func dismiss() { 46 | presentationMode.wrappedValue.dismiss() 47 | } 48 | } 49 | 50 | struct NewSprintView_Previews: PreviewProvider { 51 | static var previews: some View { 52 | NewSprintView(spaceId: "", createdSprint: .constant(nil)) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /RunningOrder/Sprint/Views/SprintList+Logic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SprintList+Logic.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 19/02/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import SwiftUI 12 | 13 | extension SprintList { 14 | final class Logic: ObservableObject { 15 | private var cancellables = Set() 16 | private unowned var sprintManager: SprintManager 17 | 18 | @Published var isNewSprintModalPresented = false 19 | 20 | var createdSprintBinding: Binding { Binding(callback: self.addSprint(_:)) } 21 | 22 | init(sprintManager: SprintManager) { 23 | self.sprintManager = sprintManager 24 | } 25 | 26 | private func addSprint(_ sprint: Sprint) { 27 | sprintManager.add(sprint: sprint) 28 | .ignoreOutput() 29 | .sink(receiveFailure: { failure in 30 | Logger.error.log(failure) // TODO error Handling 31 | }) 32 | .store(in: &cancellables) 33 | } 34 | 35 | func showNewSprintModal() { 36 | isNewSprintModalPresented = true 37 | } 38 | 39 | func deleteSprint(_ sprint: Sprint) { 40 | self.sprintManager.delete(sprint: sprint) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /RunningOrder/Sprint/Views/SprintList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SprintList.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 07/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | extension Sprint: Identifiable {} 13 | 14 | extension SprintList { 15 | fileprivate struct InternalView: View { 16 | @EnvironmentObject var sprintManager: SprintManager 17 | @EnvironmentObject var storyManager: StoryManager 18 | @ObservedObject var logic: Logic 19 | let space: Space 20 | 21 | var body: some View { 22 | List { 23 | Section(header: Text("Active Sprints")) { 24 | ForEach(sprintManager.sprints, id: \.self) { sprint in 25 | NavigationLink( 26 | destination: StoryList(sprint: sprint), 27 | label: { 28 | HStack { 29 | SprintNumber( 30 | number: sprint.number, 31 | colorIdentifier: sprint.colorIdentifier 32 | ) 33 | Text(sprint.name) 34 | } 35 | } 36 | ) 37 | .contextMenu { 38 | Button( 39 | action: { logic.deleteSprint(sprint) }, 40 | label: { Text("Delete Sprint") } 41 | ) 42 | } 43 | } 44 | } 45 | Section(header: Text("Old Sprints")) { 46 | EmptyView() 47 | } 48 | } 49 | .overlay(Button(action: self.logic.showNewSprintModal) { 50 | HStack { 51 | Image(nsImageName: NSImage.addTemplateName) 52 | .frame(width: 20, height: 20) 53 | .foregroundColor(.white) 54 | .background(Color.accentColor) 55 | .clipShape(Circle()) 56 | Text("New Sprint") 57 | .foregroundColor(Color.accentColor) 58 | .font(.system(size: 12)) 59 | } 60 | } 61 | .padding(.all, 20.0) 62 | .buttonStyle(PlainButtonStyle()), alignment: .bottom) 63 | .sheet(isPresented: $logic.isNewSprintModalPresented) { 64 | NewSprintView(spaceId: space.id, createdSprint: self.logic.createdSprintBinding) 65 | } 66 | } 67 | } 68 | } 69 | 70 | struct SprintList: View { 71 | @EnvironmentObject var sprintManager: SprintManager 72 | let space: Space 73 | 74 | var body: some View { 75 | InternalView(logic: Logic(sprintManager: sprintManager), space: space) 76 | } 77 | } 78 | 79 | struct SprintList_Previews: PreviewProvider { 80 | static var previews: some View { 81 | SprintList(space: Space(name: "toto")) 82 | .environmentObject(SprintManager.preview) 83 | .frame(width: 250) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /RunningOrder/Sprint/Views/SprintNumber.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 20/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SprintNumber: View { 12 | let number: Int 13 | let colorIdentifier: String 14 | 15 | var body: some View { 16 | Text("\(number)") 17 | .foregroundColor(Color.white) 18 | .frame(width: 25, height: 12) 19 | .padding(.all, 2) 20 | .background(Color(colorIdentifier)) 21 | .clipShape(RoundedRectangle(cornerRadius: 4)) 22 | } 23 | } 24 | 25 | struct SwiftUIView_Previews: PreviewProvider { 26 | static var previews: some View { 27 | SprintNumber(number: 454, colorIdentifier: "elf green") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /RunningOrder/Story/Extensions/Story+CloudKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Story+CloudKit.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 20/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CloudKit 11 | 12 | extension Story: CKRecordable { 13 | 14 | init(from record: CKRecord) throws { 15 | self.name = try record.property("name") 16 | self.ticketReference = try record.property("ticketReference") 17 | self.epic = try record.property("epic") 18 | 19 | let sprintReference: CKRecord.Reference = try record.property("sprintId") 20 | self.sprintId = sprintReference.recordID.recordName 21 | 22 | if let id = record.creatorUserRecordID { 23 | self.creatorReference = UserReference(recordId: id) 24 | } else { 25 | self.creatorReference = nil 26 | } 27 | } 28 | 29 | func encode(zoneId: CKRecordZone.ID) -> CKRecord { 30 | let storyRecord = CKRecord(recordType: RecordType.story.rawValue, recordID: recordId(zoneId: zoneId)) 31 | 32 | storyRecord["name"] = self.name 33 | storyRecord["ticketReference"] = self.ticketReference 34 | storyRecord["epic"] = self.epic 35 | storyRecord["sprintId"] = CKRecord.Reference(recordID: sprintRecordId(zoneId: zoneId), action: .deleteSelf) 36 | 37 | storyRecord.parent = CKRecord.Reference(recordID: sprintRecordId(zoneId: zoneId), action: .none) 38 | 39 | return storyRecord 40 | } 41 | 42 | private func recordId(zoneId: CKRecordZone.ID) -> CKRecord.ID { 43 | return CKRecord.ID(recordName: self.id, zoneID: zoneId) 44 | } 45 | 46 | private func sprintRecordId(zoneId: CKRecordZone.ID) -> CKRecord.ID { 47 | return CKRecord.ID(recordName: self.sprintId, zoneID: zoneId) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /RunningOrder/Story/Managers/StoryInformationManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryInformationManager.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 12/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import Combine 12 | import CloudKit 13 | 14 | final class StoryInformationManager: ObservableObject { 15 | 16 | @Published var storyInformations: [Story.ID: StoryInformation] = [:] 17 | 18 | @Published var storyInformationsBuffer: [Story.ID: StoryInformation] = [:] 19 | 20 | var cancellables: Set = [] 21 | 22 | private let service: StoryInformationService 23 | 24 | init(service: StoryInformationService, dataPublisher: AnyPublisher) { 25 | self.service = service 26 | 27 | dataPublisher.sink(receiveValue: { [weak self] informations in 28 | self?.updateData(with: informations.toUpdate) 29 | self?.deleteData(recordIds: informations.toDelete) 30 | }).store(in: &cancellables) 31 | 32 | // saving storyinformation while live editing in the list component, each modification is stored in the buffer in order to persist it 33 | // when the saving operation is sent to the cloud, we empty the buffer 34 | // the throttle is here to reduce the number of operation 35 | $storyInformationsBuffer 36 | .filter { !$0.isEmpty } 37 | .throttle(for: 4.0, scheduler: DispatchQueue.main, latest: true) 38 | .sink { value in 39 | self.service.save(storyInformations: Array(value.values)) 40 | .ignoreOutput() 41 | .sink (receiveFailure: { failure in 42 | Logger.error.log(failure) // TODO error Handling 43 | }) 44 | .store(in: &self.cancellables) 45 | 46 | self.storyInformationsBuffer.removeAll() 47 | } 48 | .store(in: &cancellables) 49 | } 50 | 51 | func informations(for storyId: Story.ID) -> Binding { 52 | return Binding { 53 | self.storyInformations[storyId] ?? StoryInformation(storyId: storyId) 54 | } set: { newValue in 55 | self.storyInformations[storyId] = newValue 56 | self.storyInformationsBuffer[storyId] = newValue 57 | } 58 | } 59 | 60 | func updateData(with updatedRecords: [CKRecord]) { 61 | do { 62 | let updatedStoryInformationArray = try updatedRecords 63 | .map(StoryInformation.init(from:)) 64 | .map { ($0.storyId, $0) } 65 | 66 | let updatedDictionary = [Story.ID: StoryInformation](updatedStoryInformationArray) { _, new in new } 67 | DispatchQueue.main.async { 68 | self.storyInformations.merge(updatedDictionary, uniquingKeysWith: { _, new in new }) 69 | } 70 | 71 | } catch { 72 | Logger.error.log(error) 73 | } 74 | } 75 | 76 | func deleteData(recordIds: [CKRecord.ID]) { 77 | for recordId in recordIds { 78 | if let existingReference = storyInformations.keys.first(where: { StoryInformation.recordName(for: $0) == recordId.recordName}) { 79 | storyInformations[existingReference] = nil 80 | } else { 81 | Logger.warning.log("storyInformation not found when deleting \(recordId.recordName)") 82 | } 83 | } 84 | } 85 | } 86 | 87 | extension StoryInformationManager { 88 | static let preview = StoryInformationManager(service: StoryInformationService(), dataPublisher: Empty().eraseToAnyPublisher()) 89 | } 90 | -------------------------------------------------------------------------------- /RunningOrder/Story/Managers/StoryManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryManager.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 12/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | import CloudKit 12 | 13 | ///The class responsible of managing the Story data, this is the only source of truth 14 | final class StoryManager: ObservableObject { 15 | @Published var stories: [Sprint.ID: [Story]] = [:] 16 | 17 | var epics: Set { 18 | return stories 19 | .values 20 | .reduce(Set()) { result, values in 21 | result.union(values.map { $0.epic }) 22 | } 23 | } 24 | 25 | var cancellables: Set = [] 26 | 27 | private let service: StoryService 28 | private let userService: UserService 29 | 30 | init(service: StoryService, userService: UserService, dataPublisher: AnyPublisher) { 31 | self.service = service 32 | self.userService = userService 33 | 34 | dataPublisher.sink(receiveValue: { [weak self] informations in 35 | self?.updateData(with: informations.toUpdate) 36 | self?.deleteData(recordIds: informations.toDelete) 37 | }).store(in: &cancellables) 38 | } 39 | 40 | func add(story: Story) -> AnyPublisher { 41 | let saveStoryPublisher = service.save(story: story) 42 | .share() 43 | .receive(on: DispatchQueue.main) 44 | 45 | saveStoryPublisher 46 | .catchAndExit { _ in } // we do nothing if an error occurred 47 | .append(to: \.stories[story.sprintId], onStrong: self) // we add append the Story Output to the Story array associates with sprintId 48 | .store(in: &cancellables) 49 | 50 | return saveStoryPublisher.eraseToAnyPublisher() 51 | } 52 | 53 | func updateData(with updatedRecords: [CKRecord]) { 54 | do { 55 | let updatedStories = try updatedRecords 56 | .sorted(by: { ($0.creationDate ?? Date()) < ($1.creationDate ?? Date()) }) 57 | .map(Story.init(from:)) 58 | 59 | var currentStories = self.stories 60 | for story in updatedStories { 61 | if let index = currentStories[story.sprintId]?.firstIndex(where: { $0.id == story.id }) { 62 | currentStories[story.sprintId]?[index] = story 63 | } else { 64 | Logger.warning.log("story with id \(story.id) not found, so appending it to existing story list") 65 | if currentStories.index(forKey: story.sprintId) == nil { 66 | currentStories[story.sprintId] = [story] 67 | } else { 68 | currentStories[story.sprintId]?.append(story) 69 | } 70 | } 71 | } 72 | DispatchQueue.main.async { 73 | self.stories = currentStories 74 | } 75 | } catch { 76 | Logger.error.log(error) 77 | } 78 | } 79 | 80 | private func findExistingStory(for recordId: CKRecord.ID) -> (sprintId: Sprint.ID, index: Int)? { 81 | for (sprintId, stories) in stories { 82 | if let index = stories.firstIndex(where: { $0.id == recordId.recordName }) { 83 | return (sprintId, index) 84 | } 85 | } 86 | 87 | return nil 88 | } 89 | 90 | func getUser(creatorOf story: Story) -> AnyPublisher { 91 | guard let reference = story.creatorReference else { 92 | return Fail(outputType: User.self, failure: BasicError.noValue).eraseToAnyPublisher() 93 | } 94 | 95 | return userService 96 | .fetch(userReference: reference) 97 | .receive(on: DispatchQueue.main) 98 | .eraseToAnyPublisher() 99 | } 100 | 101 | func delete(story: Story) { 102 | guard let (sprintId, index) = findExistingStory(for: CKRecord.ID(recordName: story.id)) else { 103 | Logger.error.log("couldn't find index of story in stored stories") 104 | return 105 | } 106 | 107 | service.delete(story: story) 108 | .receive(on: DispatchQueue.main) 109 | .sink(receiveCompletion: { [weak self] completion in 110 | switch completion { 111 | case .failure(let error): 112 | Logger.error.log(error) // TODO: error Handling 113 | case .finished: 114 | self?.stories[sprintId]?.remove(at: index) 115 | if self?.stories[sprintId]?.isEmpty ?? false { 116 | self?.stories.removeValue(forKey: sprintId) 117 | } 118 | } 119 | }) 120 | .store(in: &cancellables) 121 | } 122 | 123 | func deleteData(recordIds: [CKRecord.ID]) { 124 | for recordId in recordIds { 125 | if let existingReference = findExistingStory(for: recordId) { 126 | DispatchQueue.main.async { 127 | self.stories[existingReference.sprintId]!.remove(at: existingReference.index) 128 | } 129 | } else { 130 | Logger.warning.log("story not found when deleting \(recordId.recordName)") 131 | } 132 | } 133 | } 134 | 135 | var allStories: [Story] { 136 | return stories.values.flatMap { $0 } 137 | } 138 | 139 | /// Returns the stories of a specific sprintId, in case of selected searchItem move to search mode 140 | /// - Parameter sprintId: The id of the sprint 141 | /// - Parameter searchItem: potential search item 142 | /// - Returns: Retrieved stories 143 | func stories(for sprintId: Sprint.ID, searchItem: SearchItem? = nil) -> [Story] { 144 | var retrievedStories: [Story] = [] 145 | 146 | if let selectedItem = searchItem { 147 | switch selectedItem.type { 148 | case .epic: 149 | retrievedStories = allStories.filter { $0.epic == selectedItem.name } 150 | case .story: 151 | if let selectedStory = selectedItem.relatedStory { 152 | retrievedStories = [selectedStory] 153 | } 154 | case .people: 155 | break 156 | } 157 | } else { 158 | retrievedStories = stories[sprintId] ?? [] 159 | } 160 | 161 | return retrievedStories 162 | } 163 | } 164 | 165 | extension StoryManager { 166 | static let preview = StoryManager(service: StoryService(), userService: UserService(), dataPublisher: Empty().eraseToAnyPublisher()) 167 | } 168 | -------------------------------------------------------------------------------- /RunningOrder/Story/Models/Configuration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Configuration.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 07/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Configuration { 12 | var environments: [String] = [] 13 | var mocks: [String] = [] 14 | var features: [String] = [] 15 | var indicators: [String] = [] 16 | var identifiers: [String] = [] 17 | } 18 | 19 | extension Configuration: Equatable { } 20 | extension Configuration: Hashable { } 21 | -------------------------------------------------------------------------------- /RunningOrder/Story/Models/Link.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Lien.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 07/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Link: Identifiable { 12 | var id = UUID() 13 | var label: String 14 | var url: String 15 | 16 | init(label: String = "", url: String = "") { 17 | self.label = label 18 | self.url = url 19 | } 20 | 21 | var formattedURL: URL? { 22 | guard let url = URL(string: self.url) else { return nil } 23 | 24 | return url 25 | } 26 | } 27 | 28 | extension Link: Equatable { } 29 | extension Link: Hashable { } 30 | extension Link: Codable {} 31 | -------------------------------------------------------------------------------- /RunningOrder/Story/Models/Story.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Story.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 07/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Story { 12 | let sprintId: Sprint.ID 13 | let name: String 14 | let ticketReference: String 15 | let epic: String 16 | 17 | let creatorReference: UserReference? 18 | } 19 | 20 | extension Story { 21 | // swiftlint:disable:next type_name 22 | typealias ID = String 23 | var id: String { ticketReference } 24 | } 25 | extension Story: Equatable { } 26 | extension Story: Hashable { } 27 | -------------------------------------------------------------------------------- /RunningOrder/Story/Models/StoryInformation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryInformations.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 12/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UniformTypeIdentifiers 11 | 12 | struct StoryInformation { 13 | let storyId: Story.ID 14 | 15 | var configuration = Configuration() 16 | var links: [Link] = [] 17 | 18 | var steps: [String] = [] 19 | 20 | var videoUrl: URL? { 21 | didSet { 22 | let fileManager = FileManager.default 23 | if let newExtension = videoUrl?.pathExtension, !newExtension.isEmpty { 24 | videoExtension = newExtension 25 | } else if let oldUrl = oldValue, 26 | let currentExtension = videoExtension, 27 | videoUrl == nil, 28 | let symbolicUrl = try? Self.symbolicVideoUrl(videoUrl: oldUrl, videoExtension: currentExtension, fileManager: fileManager), 29 | fileManager.fileExists(atPath: symbolicUrl.path) { 30 | try? fileManager.removeItem(at: symbolicUrl) 31 | self.videoExtension = nil 32 | } 33 | } 34 | } 35 | 36 | private var videoExtension: String? 37 | 38 | init(storyId: Story.ID, configuration: Configuration = Configuration(), steps: [String] = [], videoUrl: URL? = nil) { 39 | self.storyId = storyId 40 | self.configuration = configuration 41 | self.steps = steps 42 | self.videoUrl = videoUrl 43 | if let newExtension = videoUrl?.pathExtension, !newExtension.isEmpty { 44 | self.videoExtension = videoUrl?.pathExtension 45 | } 46 | } 47 | } 48 | 49 | extension StoryInformation: Equatable {} 50 | extension StoryInformation: Hashable {} 51 | 52 | extension StoryInformation { 53 | static func symbolicVideoUrl(videoUrl: URL, videoExtension: String, fileManager: FileManager) throws -> URL { 54 | var symbolicUrl = try fileManager.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false) 55 | symbolicUrl.appendPathComponent(videoUrl.lastPathComponent) 56 | symbolicUrl.appendPathExtension(videoExtension) 57 | 58 | return symbolicUrl 59 | } 60 | 61 | func createSymbolicVideoUrlIfNeeded(with fileManager: FileManager) -> URL? { 62 | guard let videoUrl = videoUrl, let videoExtension = videoExtension else { return nil } 63 | 64 | // If the type corresponding to the pathExtension doesn't exist, it means it's a file from CKAsset, 65 | // without extension, we then create a symbolic link with the extension 66 | if UTType(filenameExtension: videoUrl.pathExtension, conformingTo: .movie)?.isDynamic ?? true { 67 | do { 68 | let symbolicUrl = try Self.symbolicVideoUrl(videoUrl: videoUrl, videoExtension: videoExtension, fileManager: fileManager) 69 | 70 | if fileManager.fileExists(atPath: symbolicUrl.path) { 71 | Logger.debug.log("this symbolic link already exist, no creation") 72 | } else { 73 | try fileManager.createSymbolicLink(at: symbolicUrl, withDestinationURL: videoUrl) 74 | } 75 | 76 | return symbolicUrl 77 | } catch { 78 | Logger.error.log(error) 79 | return nil 80 | } 81 | } else { 82 | return videoUrl 83 | } 84 | } 85 | } 86 | 87 | import CloudKit 88 | 89 | // Extension here to access to a private property of the struct. 90 | extension StoryInformation: CKRecordable { 91 | 92 | init(from record: CKRecord) throws { 93 | let storyReference: CKRecord.Reference = try record.property("storyId") 94 | self.storyId = storyReference.recordID.recordName 95 | 96 | self.steps = try record.property("steps") 97 | let environments: [String] = try record.property("environments") 98 | let mocks: [String] = try record.property("mocks") 99 | let features: [String] = try record.property("features") 100 | let indicators: [String] = try record.property("indicators") 101 | let identifiers: [String] = try record.property("identifiers") 102 | 103 | let linksData: Data = try record.property("links") 104 | let decoder: JSONDecoder = JSONDecoder() 105 | self.links = try decoder.decode([Link].self, from: linksData) 106 | 107 | let videoAsset: CKAsset? = try? record.property("video") 108 | self.videoUrl = videoAsset?.fileURL 109 | self.videoExtension = try? record.property("videoExtension") 110 | 111 | self.configuration = .init(environments: environments, mocks: mocks, features: features, indicators: indicators, identifiers: identifiers) 112 | } 113 | 114 | func encode(zoneId: CKRecordZone.ID) -> CKRecord { 115 | let storyInformationRecord = CKRecord(recordType: RecordType.storyInformation.rawValue, recordID: recordId(zoneId: zoneId)) 116 | 117 | storyInformationRecord["storyId"] = CKRecord.Reference(recordID: storyRecordId(zoneId: zoneId), action: .deleteSelf) 118 | storyInformationRecord.parent = CKRecord.Reference(recordID: storyRecordId(zoneId: zoneId), action: .none) 119 | storyInformationRecord["steps"] = self.steps 120 | 121 | // Configuration 122 | 123 | storyInformationRecord["environments"] = self.configuration.environments 124 | storyInformationRecord["mocks"] = self.configuration.mocks 125 | storyInformationRecord["features"] = self.configuration.features 126 | storyInformationRecord["indicators"] = self.configuration.indicators 127 | storyInformationRecord["identifiers"] = self.configuration.identifiers 128 | 129 | // Links : for now as a link label is only the url string representation we can only store all the labels, will change in the future 130 | 131 | let encoder: JSONEncoder = JSONEncoder() 132 | 133 | do { 134 | storyInformationRecord["links"] = try encoder.encode(self.links) 135 | } catch { 136 | storyInformationRecord["links"] = try? encoder.encode([Link]()) 137 | Logger.error.log(error) 138 | } 139 | 140 | if let videoUrl = self.videoUrl { 141 | storyInformationRecord["video"] = CKAsset(fileURL: videoUrl) 142 | storyInformationRecord["videoExtension"] = self.videoExtension 143 | } else { 144 | storyInformationRecord["video"] = nil 145 | storyInformationRecord["videoExtension"] = nil 146 | 147 | } 148 | 149 | return storyInformationRecord 150 | } 151 | 152 | private func recordId(zoneId: CKRecordZone.ID) -> CKRecord.ID { 153 | return CKRecord.ID(recordName: Self.recordName(for: self.storyId), zoneID: zoneId) // we construct an unique ID based on the storyID 154 | } 155 | 156 | static func recordName(for storyId: String) -> String { 157 | return "si-\(storyId)" 158 | } 159 | 160 | private func storyRecordId(zoneId: CKRecordZone.ID) -> CKRecord.ID { 161 | return CKRecord.ID(recordName: self.storyId, zoneID: zoneId) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /RunningOrder/Story/Services/StoryInformationService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryInformationService.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 26/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import CloudKit 12 | 13 | /// The service responsible of all the StoryInformation CRUD operation 14 | class StoryInformationService { 15 | let cloudkitContainer = CloudKitContainer.shared 16 | 17 | func save(storyInformations: [StoryInformation]) -> AnyPublisher<[StoryInformation], Swift.Error> { 18 | let storyInformationRecords = storyInformations.map { $0.encode(zoneId: cloudkitContainer.sharedZoneId) } 19 | 20 | let saveOperation = CKModifyRecordsOperation() 21 | saveOperation.recordsToSave = storyInformationRecords 22 | 23 | let configuration = CKOperation.Configuration() 24 | configuration.qualityOfService = .utility 25 | 26 | saveOperation.configuration = configuration 27 | saveOperation.savePolicy = .allKeys // save policy to handle update 28 | 29 | cloudkitContainer.currentDatabase.add(saveOperation) 30 | 31 | return saveOperation.publishers().perRecord 32 | .tryMap { try StoryInformation.init(from: $0) } 33 | .collect() 34 | .eraseToAnyPublisher() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /RunningOrder/Story/Services/StoryService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryService.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 26/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import CloudKit 12 | 13 | /// The service responsible of all the Story CRUD operation 14 | class StoryService { 15 | let cloudkitContainer = CloudKitContainer.shared 16 | 17 | func save(story: Story) -> AnyPublisher { 18 | let storyRecord = story.encode(zoneId: cloudkitContainer.sharedZoneId) 19 | 20 | let saveOperation = CKModifyRecordsOperation() 21 | saveOperation.recordsToSave = [storyRecord] 22 | 23 | let configuration = CKOperation.Configuration() 24 | configuration.qualityOfService = .utility 25 | 26 | saveOperation.configuration = configuration 27 | 28 | cloudkitContainer.currentDatabase.add(saveOperation) 29 | 30 | return saveOperation.publishers().perRecord 31 | .tryMap { try Story.init(from: $0) } 32 | .eraseToAnyPublisher() 33 | } 34 | 35 | func delete(story: Story) -> AnyPublisher { 36 | let record = story.encode(zoneId: cloudkitContainer.sharedZoneId) 37 | let deleteOperation = CKModifyRecordsOperation(recordsToSave: nil, recordIDsToDelete: [record.recordID]) 38 | 39 | let configuration = CKOperation.Configuration() 40 | configuration.qualityOfService = .utility 41 | 42 | deleteOperation.configuration = configuration 43 | 44 | cloudkitContainer.currentDatabase.add(deleteOperation) 45 | 46 | return deleteOperation.publishers() 47 | .completion 48 | .ignoreOutput() 49 | .eraseToAnyPublisher() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /RunningOrder/Story/Views/ConfigurationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StepsView.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 25/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ConfigurationView: View { 12 | @Binding var storyInformation: StoryInformation 13 | 14 | var body: some View { 15 | ScrollView { 16 | VStack(alignment: .leading, spacing: 15) { 17 | Text("Configuration") 18 | .font(.title2) 19 | .padding(.leading, 12) 20 | 21 | InlineEditableList(title: "Environments", values: $storyInformation.configuration.environments) 22 | 23 | InlineEditableList(title: "Mock", values: $storyInformation.configuration.mocks) 24 | 25 | InlineEditableList(title: "Feature flip", values: $storyInformation.configuration.features) 26 | 27 | InlineEditableList(title: "Indicators", values: $storyInformation.configuration.indicators) 28 | 29 | InlineEditableList(title: "Identifier", values: $storyInformation.configuration.identifiers) 30 | 31 | InlineEditableLinkList(title: "Link", values: $storyInformation.links) 32 | 33 | Spacer() 34 | } 35 | .padding(.horizontal, 10) 36 | } 37 | } 38 | } 39 | 40 | struct StepsView_Previews: PreviewProvider { 41 | static var previews: some View { 42 | ConfigurationView(storyInformation: .constant(StoryInformation(storyId: ""))) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /RunningOrder/Story/Views/NewStoryView+Logic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewStoryView+Logic.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 19/02/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | extension NewStoryView { 13 | final class Logic: ObservableObject, TextfieldEditingStringHandler { 14 | let sprintId: Sprint.ID 15 | @Binding var createdStory: Story? 16 | 17 | @Published var name = "" 18 | @Published var ticketID = "" 19 | @Published var epic = "" 20 | 21 | var dismissSubject = PassthroughSubject() 22 | 23 | var areAllFieldsFilled: Bool { 24 | return !ticketID.isEmpty && !name.isEmpty && !epic.isEmpty 25 | } 26 | 27 | init(sprintId: Sprint.ID, createdStory: Binding) { 28 | self.sprintId = sprintId 29 | self._createdStory = createdStory 30 | } 31 | 32 | func createStory() { 33 | guard areAllFieldsFilled else { return } 34 | 35 | // No need of creator reference as it is added by cloudkit. 36 | // When this record is created, it is sent to cloudkit, that updates it. With the notification subscription, we receive the new record, and update this story with the user reference. 37 | // TLDR; no need to set the creatorReference ourselves. 38 | let newStory = Story( 39 | sprintId: sprintId, 40 | name: name, 41 | ticketReference: ticketID, 42 | epic: epic, 43 | creatorReference: nil 44 | ) 45 | self.createdStory = newStory 46 | dismissSubject.send(()) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /RunningOrder/Story/Views/NewStoryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewStory`View.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 27/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct NewStoryView: View { 12 | @ObservedObject var logic: Logic 13 | @Environment(\.presentationMode) var presentationMode 14 | 15 | init(sprintId: Sprint.ID, createdStory: Binding) { 16 | self.logic = Logic(sprintId: sprintId, createdStory: createdStory) 17 | } 18 | 19 | var body: some View { 20 | VStack { 21 | TextField( 22 | "Story Name", 23 | text: $logic.name, 24 | onEditingChanged: logic.fieldEditingChanged(valueKeyPath: \.name) 25 | ) 26 | TextField( 27 | "Ticket ID", 28 | text: $logic.ticketID, 29 | onEditingChanged: logic.fieldEditingChanged(valueKeyPath: \.ticketID) 30 | ) 31 | TextField( 32 | "Story EPIC", 33 | text: $logic.epic, 34 | onEditingChanged: logic.fieldEditingChanged(valueKeyPath: \.epic), 35 | onCommit: logic.createStory 36 | ) 37 | 38 | HStack { 39 | Button(action: dismiss) { Text("Cancel") } 40 | Spacer() 41 | Button(action: logic.createStory) { Text("Create") } 42 | .disabled(!logic.areAllFieldsFilled) 43 | } 44 | } 45 | .padding() 46 | .onReceive(logic.dismissSubject, perform: dismiss) 47 | } 48 | 49 | private func dismiss() { 50 | presentationMode.wrappedValue.dismiss() 51 | } 52 | } 53 | 54 | struct NewStoryView_Previews: PreviewProvider { 55 | static var previews: some View { 56 | NewStoryView(sprintId: Sprint.Previews.sprints[0].id, createdStory: .constant(nil)) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /RunningOrder/Story/Views/StepsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConfigurationView.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 25/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct StepsView: View { 12 | @Binding var storyInformation: StoryInformation 13 | @State private var selectedMode = DisplayMode.video 14 | 15 | var body: some View { 16 | ScrollView { 17 | VStack { 18 | Picker("", selection: $selectedMode) { 19 | ForEach(DisplayMode.allCases, id: \.self) { choice in 20 | Text(choice.rawValue) 21 | } 22 | } 23 | .padding() 24 | .pickerStyle(SegmentedPickerStyle()) 25 | 26 | switch selectedMode { 27 | case .steps: 28 | InlineEditableList(title: "Steps", placeholder: "A step to follow", values: self.$storyInformation.steps) 29 | 30 | case .video: 31 | VideoView(storyInformation: $storyInformation) 32 | } 33 | Spacer() 34 | } 35 | } 36 | } 37 | } 38 | 39 | private enum DisplayMode: LocalizedStringKey, CaseIterable { 40 | case video = "Video" 41 | case steps = "Steps" 42 | } 43 | 44 | struct ConfigurationView_Previews: PreviewProvider { 45 | static var previews: some View { 46 | StepsView(storyInformation: .constant(StoryInformation(storyId: ""))) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /RunningOrder/Story/Views/StoryDetail.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryDetail.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 07/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct StoryDetail: View { 12 | let story: Story 13 | 14 | @EnvironmentObject var storyInformationManager: StoryInformationManager 15 | @EnvironmentObject var storyManager: StoryManager 16 | 17 | var informationBinding: Binding { storyInformationManager.informations(for: story.id) } 18 | 19 | var body: some View { 20 | VStack(alignment: .leading, spacing: 10) { 21 | StoryDetailHeader(story: story) 22 | .padding(10) 23 | Divider() 24 | .padding(10) 25 | HSplitView { 26 | ConfigurationView(storyInformation: informationBinding) 27 | StepsView(storyInformation: informationBinding) 28 | } 29 | } 30 | .background(Color(NSColor.controlBackgroundColor)) 31 | .toolbar { 32 | ToolbarItemGroup { 33 | Text(story.name) 34 | .font(.title3) 35 | .fontWeight(.semibold) 36 | 37 | Spacer() 38 | } 39 | 40 | ToolbarItem(placement: ToolbarItemPlacement.cancellationAction) { 41 | SearchBarView().frame(width: 300) 42 | } 43 | 44 | ToolbarItems.deleteStory(storyManager: storyManager, story: story) 45 | } 46 | .onAppear { storyManager.getUser(creatorOf: story) } 47 | } 48 | } 49 | 50 | struct StoryDetail_Previews: PreviewProvider { 51 | static var previews: some View { 52 | StoryDetail(story: Story.Previews.stories[0]) 53 | .environmentObject(StoryInformationManager.preview) 54 | .environmentObject(StoryManager.preview) 55 | .environmentObject(SpaceManager.preview) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /RunningOrder/Story/Views/StoryDetailHeader+Logic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryDetailHeader+Logic.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 12/03/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | extension StoryDetailHeader { 13 | final class Logic: ObservableObject { 14 | @Published var userName: String? 15 | 16 | private unowned var storyManager: StoryManager 17 | 18 | init(storyManager: StoryManager) { 19 | self.storyManager = storyManager 20 | } 21 | 22 | func fetchUsername(for story: Story) { 23 | storyManager.getUser(creatorOf: story) 24 | .catchAndExit({ error in Logger.error.log(error) }) 25 | .map(\.name) 26 | .assign(to: &$userName) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /RunningOrder/Story/Views/StoryDetailHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryDetailHeader.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 10/08/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | extension StoryDetailHeader { 12 | struct InternalView: View { 13 | @Environment(\.epicColor) var epicColor 14 | let story: Story 15 | @ObservedObject var logic: Logic 16 | 17 | var body: some View { 18 | HStack { 19 | Tag(story.ticketReference, color: Color.gray) 20 | Tag(story.epic, color: epicColor) 21 | Spacer() 22 | if let userName = logic.userName { 23 | Label(userName, systemImage: "person.circle.fill") 24 | .font(Font.title2.bold()) 25 | } 26 | } 27 | .onAppear { 28 | logic.fetchUsername(for: story) 29 | } 30 | } 31 | } 32 | } 33 | 34 | struct StoryDetailHeader: View { 35 | @EnvironmentObject var storyManager: StoryManager 36 | let story: Story 37 | 38 | var body: some View { 39 | InternalView(story: story, logic: Logic(storyManager: storyManager)) 40 | } 41 | } 42 | 43 | struct StoryDetailHeader_Previews: PreviewProvider { 44 | static var previews: some View { 45 | StoryDetailHeader(story: Story.Previews.stories[0]) 46 | .environmentObject(StoryManager.preview) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /RunningOrder/Story/Views/StoryList+Logic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryList+Logic.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 19/02/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | extension StoryList { 13 | final class Logic: ObservableObject { 14 | private var cancellables = Set() 15 | private unowned var storyManager: StoryManager 16 | private unowned var searchManager: SearchManager 17 | 18 | @Published var isAddStoryViewDisplayed: Bool = false 19 | 20 | var createdStoryBinding: Binding { Binding(callback: self.addStory(_:)) } 21 | 22 | var isItemSelected: Bool { 23 | searchManager.isItemSelected 24 | } 25 | 26 | init(storyManager: StoryManager, searchManager: SearchManager) { 27 | self.storyManager = storyManager 28 | self.searchManager = searchManager 29 | } 30 | 31 | private func addStory(_ story: Story) { 32 | storyManager.add(story: story) 33 | .ignoreOutput() 34 | .sink(receiveFailure: { error in Logger.error.log(error) }) // TODO Clean error handling 35 | .store(in: &cancellables) 36 | } 37 | 38 | func deleteStory(_ story: Story) { 39 | self.storyManager.delete(story: story) 40 | } 41 | 42 | func showAddStoryView() { 43 | isAddStoryViewDisplayed = true 44 | } 45 | 46 | func epicColor(for story: Story) -> Color.Identifier { 47 | let epicIndex = (Array(storyManager.epics).sorted().firstIndex(of: story.epic) ?? 0) % Color.Identifier.epicColors.count 48 | 49 | return Color.Identifier.epicColors[epicIndex] 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /RunningOrder/Story/Views/StoryList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryList.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 07/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | extension Story: Identifiable {} 12 | 13 | extension StoryList { 14 | fileprivate struct InternalView: View { 15 | let sprint: Sprint 16 | 17 | @ObservedObject var logic: Logic 18 | @EnvironmentObject var storyManager: StoryManager 19 | @EnvironmentObject var searchManager: SearchManager 20 | @State private var selected: Story? 21 | 22 | var body: some View { 23 | List(storyManager.stories(for: sprint.id, searchItem: searchManager.selectedSearchItem), id: \.self, selection: $selected) { story in 24 | VStack { 25 | NavigationLink( 26 | destination: StoryDetail(story: story).epicColor(Color(identifier: logic.epicColor(for: story))), 27 | label: { StoryRow(story: story) } 28 | ) 29 | .contextMenu { 30 | Button( 31 | action: { logic.deleteStory(story) }, 32 | label: { Text("Delete Story") } 33 | ) 34 | } 35 | .epicColor(Color(identifier: logic.epicColor(for: story))) 36 | 37 | if story == selected { 38 | Divider() 39 | .hidden() 40 | } else { 41 | Divider() 42 | } 43 | } 44 | } 45 | .navigationTitle(logic.isItemSelected ? "Searching" : "Sprint \(sprint.number) - \(sprint.name)") 46 | .frame(minWidth: 100, idealWidth: 300) 47 | .toolbar { 48 | ToolbarItems.sidebarItem 49 | 50 | ToolbarItem(placement: ToolbarItemPlacement.cancellationAction) { 51 | Button(action: logic.showAddStoryView) { 52 | Image(systemName: "square.and.pencil") 53 | } 54 | } 55 | } 56 | .sheet(isPresented: $logic.isAddStoryViewDisplayed) { 57 | NewStoryView(sprintId: sprint.id, createdStory: logic.createdStoryBinding) 58 | } 59 | } 60 | } 61 | } 62 | 63 | struct StoryList: View { 64 | let sprint: Sprint 65 | @EnvironmentObject var storyManager: StoryManager 66 | @EnvironmentObject var searchManager: SearchManager 67 | 68 | var body: some View { 69 | InternalView(sprint: sprint, logic: Logic(storyManager: storyManager, searchManager: searchManager)) 70 | } 71 | } 72 | 73 | struct StoryList_Previews: PreviewProvider { 74 | static var previews: some View { 75 | StoryList(sprint: Sprint.Previews.sprints[0]) 76 | .environmentObject(StoryManager.preview) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /RunningOrder/Story/Views/StoryRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryRow.swift 3 | // RunningOrder 4 | // 5 | // Created by Lucas Barbero on 28/07/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct StoryRow: View { 12 | let story: Story 13 | @Environment(\.epicColor) var epicColor 14 | 15 | var body: some View { 16 | VStack(alignment: .leading, spacing: 8) { 17 | HStack { 18 | Tag(story.epic, color: epicColor) 19 | .font(.caption2) 20 | 21 | Spacer() 22 | 23 | Text(story.ticketReference) 24 | .foregroundColor(.secondary) 25 | .font(.caption2) 26 | } 27 | Text(story.name) 28 | } 29 | .padding(5) 30 | } 31 | } 32 | 33 | struct StoryRow_Previews: PreviewProvider { 34 | static var previews: some View { 35 | StoryRow(story: Story.Previews.stories[0]) 36 | .epicColor(.blue) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RunningOrder/Story/Views/VideoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VideoView.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 19/02/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import UniformTypeIdentifiers 11 | import AVFoundation 12 | import AVKit 13 | 14 | struct VideoView: View { 15 | @Binding var storyInformation: StoryInformation 16 | 17 | @State private var isVideoDropTargeted = false 18 | @State private var isFileImporterPresented = false 19 | 20 | var avPlayer: AVPlayer? { 21 | if let videoUrl = storyInformation.createSymbolicVideoUrlIfNeeded(with: .default) { 22 | return AVPlayer(playerItem: AVPlayerItem(asset: AVAsset(url: videoUrl))) 23 | } else { 24 | return nil 25 | } 26 | } 27 | 28 | var body: some View { 29 | videoView 30 | .animation(.none) 31 | .border(Color.green, width: isVideoDropTargeted ? 4 : 0) 32 | .animation(.default) 33 | .onDrop(of: [.fileURL], isTargeted: $isVideoDropTargeted, perform: { itemProviders in 34 | guard let item = itemProviders.first else { return false } 35 | 36 | item.loadDataRepresentation(forTypeIdentifier: UTType.fileURL.identifier) { data, error in 37 | if let data = data, 38 | let string = String(data: data, encoding: .utf8), 39 | let url = URL(string: string), 40 | let fileType = UTType(filenameExtension: url.pathExtension), 41 | fileType.conforms(to: .movie) { 42 | storyInformation.videoUrl = url 43 | } else if let error = error { 44 | Logger.error.log(error) 45 | } else { 46 | Logger.debug.log("file not conforming to needs") 47 | } 48 | } 49 | return true 50 | }) 51 | .padding() 52 | .fileImporter(isPresented: $isFileImporterPresented, allowedContentTypes: [.movie]) { result in 53 | switch result { 54 | case .success(let url): 55 | storyInformation.videoUrl = url 56 | case .failure(let error): 57 | Logger.error.log(error) 58 | } 59 | } 60 | } 61 | 62 | @ViewBuilder var videoView: some View { 63 | if let avPlayer = avPlayer { 64 | VideoPlayer(player: avPlayer) 65 | .frame(height: 600) 66 | .overlay( 67 | RoundButton(image: Image(systemName: "trash"), color: .red) { 68 | storyInformation.videoUrl = nil 69 | } 70 | .frame(width: 25, height: 25) 71 | .padding(), 72 | alignment: .topTrailing 73 | ) 74 | } else { 75 | Rectangle() 76 | .frame(height: 600) 77 | .foregroundColor(.black) 78 | .overlay( 79 | RoundButton( 80 | image: Image(systemName: "tray.and.arrow.down"), 81 | color: .gray, 82 | action: { isFileImporterPresented = true } 83 | ) 84 | .frame(width: 100, height: 100) 85 | .padding(), 86 | alignment: .center 87 | ) 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /RunningOrder/User/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // User.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 12/03/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum User { 12 | case name(PersonNameComponents) 13 | case email(String) 14 | case noIdentity 15 | 16 | var name: String? { 17 | switch self { 18 | case .noIdentity: 19 | return nil 20 | case .email(let email): 21 | return email 22 | case .name(let components): 23 | return PersonNameComponentsFormatter.localizedString(from: components, style: .default) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /RunningOrder/User/UserReference.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserReference.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 12/03/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CloudKit 11 | 12 | struct UserReference { 13 | let recordId: CKRecord.ID 14 | } 15 | 16 | extension UserReference: Equatable {} 17 | extension UserReference: Hashable {} 18 | -------------------------------------------------------------------------------- /RunningOrder/User/UserService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserService.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 12/03/2021. 6 | // Copyright © 2021 Worldline. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import CloudKit 12 | 13 | /// The service responsible of all the Sprint CRUD operation 14 | class UserService { 15 | let cloudkitContainer = CloudKitContainer.shared 16 | 17 | func fetch(userReference: UserReference) -> AnyPublisher { 18 | cloudkitContainer.container.discoverUserIdentity(withUserRecordID: userReference.recordId) 19 | .map { identity in 20 | if let components = identity.nameComponents { 21 | Logger.debug.log(components) 22 | return User.name(components) 23 | } else if let emailAddress = identity.lookupInfo?.emailAddress { 24 | return User.email(emailAddress) 25 | } else { 26 | return User.noIdentity 27 | } 28 | } 29 | .eraseToAnyPublisher() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /RunningOrder/WelcomeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WelcomeView.swift 3 | // RunningOrder 4 | // 5 | // Created by Clément Nonn on 22/09/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct WelcomeView: View { 12 | 13 | @Binding var space: Space? 14 | @State private var newSpaceName = "" 15 | @State private var hasErrorOnField = false 16 | 17 | var body: some View { 18 | VStack(alignment: .leading, spacing: 20) { 19 | Text("Welcome").font(.largeTitle) 20 | Text("You don't have yet your space, or joined a shared space") 21 | 22 | VStack(alignment: .leading, spacing: 0) { 23 | HStack { 24 | TextField("My Space Name", text: $newSpaceName) 25 | .overlay(Rectangle() 26 | .strokeBorder(Color.red, lineWidth: 2.0, antialiased: true) 27 | .opacity(hasErrorOnField ? 1 : 0) 28 | .animation(.default) 29 | ) 30 | 31 | Button("Create") { 32 | withAnimation { 33 | self.hasErrorOnField = newSpaceName.isEmpty 34 | } 35 | 36 | guard !hasErrorOnField else { return } 37 | 38 | space = Space(name: newSpaceName) 39 | } 40 | } 41 | 42 | if hasErrorOnField { 43 | Text("Please enter a name for your work space") 44 | .foregroundColor(.red) 45 | .animation(.easeInOut) 46 | } 47 | } 48 | 49 | Divider() 50 | .overlay(Text("Or") 51 | .padding(.horizontal, 10) 52 | .background(Color(NSColor.controlBackgroundColor))) 53 | Text("Just open a link from your team to access this space") 54 | } 55 | .padding() 56 | .background(Color(NSColor.controlBackgroundColor)) 57 | } 58 | } 59 | 60 | struct WelcomeView_Previews: PreviewProvider { 61 | static var previews: some View { 62 | WelcomeView(space: .constant(nil)) 63 | .preferredColorScheme(.dark) 64 | 65 | WelcomeView(space: .constant(nil)) 66 | .preferredColorScheme(.light) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /RunningOrder/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | RunningOrder 4 | 5 | Created by Clément Nonn on 07/07/2020. 6 | Copyright © 2020 Worldline. All rights reserved. 7 | */ 8 | // CustomWindow - ToolBar Button 9 | "Add a story" = "Add a story"; 10 | "Sidebar" = "Sidebar"; 11 | "Show the Sidebar" = "Show the Sidebar"; 12 | // SprintsView - Section Header 13 | "Active Sprints" = "Active Sprints"; 14 | // SprintList - Button 15 | "New Sprint" = "New Sprint"; 16 | // SprintList - Section Header 17 | "Old Sprints" = "Old Sprints"; 18 | // SprintList - Placer holder 19 | "Select a Sprint" = "Select a Sprint"; 20 | 21 | // NewSprintView - Textfield Name 22 | "Sprint Name" = "Sprint Name"; 23 | "Sprint Number" = "Sprint Number"; 24 | 25 | // StoryList - Section Header 26 | "Stories" = "Stories"; 27 | // SprintList - Placer holder 28 | "Select a Story" = "Select a Story"; 29 | 30 | // NewStoryView - Textfield Name 31 | "Story Name" = "Story Name"; 32 | "Ticket ID" = "Ticket ID"; 33 | "Story EPIC" = "Story EPIC"; 34 | 35 | // StoryDetail - Textfield Name 36 | "Configuration" = "Configuration"; 37 | "Links" = "Links"; 38 | "Environments" = "Environments"; 39 | "Mock" = "Mock"; 40 | "Feature Flip" = "Feature Flip"; 41 | "Indicators" = "Indicators"; 42 | "Identifier" = "Identifier"; 43 | "Specifications" = "Specifications"; 44 | "Zeplin" = "Zeplin"; 45 | "Video" = "Video"; 46 | "Steps" = "Steps"; 47 | 48 | // Common 49 | "Cancel" = "Cancel"; 50 | "Create" = "Create"; 51 | "Edit" = "Edit"; 52 | "Done" = "Done"; 53 | 54 | // Welcome 55 | "Welcome" = "Welcome"; 56 | "You don't have yet your space, or joined a shared space" = "You don't have yet your space, or joined a shared space"; 57 | "Or" = "Or"; 58 | "My Space Name" = "My Space Name"; 59 | "Just open a link from your team to access this space" = "Just open a link from your team to access this space"; 60 | -------------------------------------------------------------------------------- /RunningOrder/fr.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | RunningOrder 4 | 5 | Created by Clément Nonn on 07/07/2020. 6 | Copyright © 2020 Worldline. All rights reserved. 7 | */ 8 | // CustomWindow - ToolBar Button 9 | "Add a story" = "Ajouter une story"; 10 | "Sidebar" = "Barre latérale"; 11 | "Show the Sidebar" = "Afficher la Barre latérale"; 12 | // SprintsView - Section Header 13 | "Active Sprints" = "Sprints en cours"; 14 | // SprintList - Button 15 | "New Sprint" = "Nouveau Sprint"; 16 | // SprintList - Section Header 17 | "Old Sprints" = "Anciens Sprints"; 18 | // SprintList - Placer holder 19 | "Select a Sprint" = "Sélectionner un Sprint"; 20 | 21 | // NewSprintView - Textfield Name 22 | "Sprint Name" = "Nom du Sprint"; 23 | "Sprint Number" = "Numéro du Sprint"; 24 | 25 | // StoryList - Section Header 26 | "Stories" = "Stories"; 27 | // SprintList - Placer holder 28 | "Select a Story" = "Sélectionner une Story"; 29 | 30 | // NewStoryView - Textfield Name 31 | "Story Name" = "Nom de la Story"; 32 | "Ticket ID" = "ID du Ticket"; 33 | "Story EPIC" = "EPIC de la Story"; 34 | 35 | // StoryDetail - Textfield Name 36 | "Configuration" = "Configuration"; 37 | "Links" = "Liens"; 38 | "Environments" = "Environnements"; 39 | "Mock" = "Mock"; 40 | "Feature Flip" = "Feature Flip"; 41 | "Indicators" = "Indicateurs"; 42 | "Identifier" = "Identifiants"; 43 | "Specifications" = "Spécifications"; 44 | "Zeplin" = "Zeplin"; 45 | "Video" = "Vidéo"; 46 | "Steps" = "Étapes"; 47 | 48 | // Common 49 | "Cancel" = "Annuler"; 50 | "Create" = "Créer"; 51 | "Edit" = "Modifier"; 52 | "Done" = "Terminer"; 53 | 54 | // Welcome 55 | "Welcome" = "Bienvenue"; 56 | "You don't have yet your space, or joined a shared space" = "Tu n'as pas encore d'espace de travail"; 57 | "Or" = "Ou"; 58 | "My Space Name" = "Nom de l'espace"; 59 | "Just open a link from your team to access this space" = "Ouvre le lien de ton équipe pour accéder à cet espace"; 60 | -------------------------------------------------------------------------------- /RunningOrderTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /RunningOrderTests/RunningOrderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RunningOrderTests.swift 3 | // RunningOrderTests 4 | // 5 | // Created by Clément Nonn on 23/06/2020. 6 | // Copyright © 2020 Worldline. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import RunningOrder 11 | 12 | class RunningOrderTests: XCTestCase { 13 | 14 | override func setUpWithError() throws { 15 | // Put setup code here. This method is called before the invocation of each test method in the class. 16 | } 17 | 18 | override func tearDownWithError() throws { 19 | // Put teardown code here. This method is called after the invocation of each test method in the class. 20 | } 21 | 22 | func testExample() throws { 23 | // This is an example of a functional test case. 24 | // Use XCTAssert and related functions to verify your tests produce the correct results. 25 | } 26 | 27 | func testPerformanceExample() throws { 28 | // This is an example of a performance test case. 29 | self.measure { 30 | // Put the code you want to measure the time of here. 31 | } 32 | } 33 | 34 | } 35 | --------------------------------------------------------------------------------