├── .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 | 
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 |
--------------------------------------------------------------------------------