diff --git a/.gitignore b/.gitignore index 551a3f5..676c1e1 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,5 @@ graph.dot Derived/ ### Tuist managed dependencies ### -Tuist/Dependencies +Tuist/.build +.package.resolved diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..bddfdd3 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +tuist = "4.9.0" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..cb0b9c2 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +dogukaandev@gmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9c8c5a1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 SwiftBuddiesTR + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..642d2d8 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,41 @@ +### Issue Link :link: + + +### Goals :soccer: + + + +### Implementation Details :construction: + + + +### Testing Details :mag: + + +### Screenshots/Gifs :camera: + +How to add gif/screenshots: +1. Drag and drop the gif/screenshot here, let it upload. +2. Copy the link and paste it like below -> +``` + +``` +3. add the appropriate file extension after it "*.gif" or "*.jpg" +- Important: Be careful not to exceed 150 points width that is specified. It makes it harder chech overall ui elements if width is wider than 150 points. + +Example usage: + + + +## PR Type +What kind of change does this PR introduce? + + + +- [ ] Bugfix +- [x] Feature +- [ ] Code style update (formatting, renaming) +- [ ] Refactoring (no functional changes, no api changes) +- [ ] Build related changes +- [ ] Documentation content changes +- [x] Other... Please describe: diff --git a/Plugins/SwiftBuddiesIOS/Package.swift b/Plugins/SwiftBuddiesIOS/Package.swift deleted file mode 100644 index 258c84c..0000000 --- a/Plugins/SwiftBuddiesIOS/Package.swift +++ /dev/null @@ -1,15 +0,0 @@ -// swift-tools-version: 5.4 - -import PackageDescription - -let package = Package( - name: "MyPlugin", - products: [ - .executable(name: "tuist-my-cli", targets: ["tuist-my-cli"]), - ], - targets: [ - .executableTarget( - name: "tuist-my-cli" - ), - ] -) diff --git a/Plugins/SwiftBuddiesIOS/Plugin.swift b/Plugins/SwiftBuddiesIOS/Plugin.swift deleted file mode 100644 index 86a1e55..0000000 --- a/Plugins/SwiftBuddiesIOS/Plugin.swift +++ /dev/null @@ -1,3 +0,0 @@ -import ProjectDescription - -let plugin = Plugin(name: "MyPlugin") \ No newline at end of file diff --git a/Plugins/SwiftBuddiesIOS/ProjectDescriptionHelpers/LocalHelper.swift b/Plugins/SwiftBuddiesIOS/ProjectDescriptionHelpers/LocalHelper.swift deleted file mode 100644 index 6f49a44..0000000 --- a/Plugins/SwiftBuddiesIOS/ProjectDescriptionHelpers/LocalHelper.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation - -public struct LocalHelper { - let name: String - - public init(name: String) { - self.name = name - } -} diff --git a/Plugins/SwiftBuddiesIOS/Sources/tuist-my-cli/main.swift b/Plugins/SwiftBuddiesIOS/Sources/tuist-my-cli/main.swift deleted file mode 100644 index 6f05969..0000000 --- a/Plugins/SwiftBuddiesIOS/Sources/tuist-my-cli/main.swift +++ /dev/null @@ -1 +0,0 @@ -print("Hello, from your Tuist Task") \ No newline at end of file diff --git a/Project.swift b/Project.swift index 8fe8caf..9cd5319 100644 --- a/Project.swift +++ b/Project.swift @@ -1,71 +1,248 @@ import ProjectDescription -import ProjectDescriptionHelpers -import MyPlugin +import Foundation extension Target { - static func makeModule(name: String, dependencies: [TargetDependency] = [], hasResources: Bool = false) -> Target { - Target( + static func featureTarget( + name: String, + productName: String, + product: Product = .staticLibrary, + dependencies: [TargetDependency], + hasResources: Bool = false + ) -> Self { + target( name: name, - platform: .iOS, - product: .framework, - productName: name, - bundleId: "com.swiftbuddies.\(name.lowercased())", - sources: ["Targets/SwiftBuddies\(name)/Sources/**"], - resources: hasResources ? ["Targets/SwiftBuddies\(name)/Resources/**"] : [], - dependencies: dependencies - ) + destinations: .iOS, + product: .staticLibrary, + productName: productName, + bundleId: "com.swiftbuddies.\(productName.lowercased())", + sources: ["SwiftBuddiesIOS/Targets/\(name)Module/Sources/**"], + resources: hasResources ? ["SwiftBuddiesIOS/Targets/\(name)Module/Resources/**"] : [], + dependencies: dependencies) } } -extension TargetDependency { - static func makeExternalTarget(name: String) -> TargetDependency { - TargetDependency.external(name: name, condition: nil) - } -} - -// MARK: - Project - -// Local plugin loaded -let localHelper = LocalHelper(name: "MyPlugin") - -let networkDependency = TargetDependency.makeExternalTarget(name: "DefaultNetworkOperationPackage") -let swiftUIXDependency = TargetDependency.makeExternalTarget(name: "SwiftUIX") -let designTarget = Target.makeModule( - name: "Design", - dependencies: [swiftUIXDependency], - hasResources: true -) - -let contributorsModule = Target.makeModule( - name: "Contributors", - dependencies: [.target(designTarget), networkDependency] -) -let mapModule = Target.makeModule( - name: "Map", - dependencies: [.target(designTarget), networkDependency] -) -let aboutModule = Target.makeModule( - name: "About", - dependencies: [.target(designTarget), networkDependency] -) -let feedModule = Target.makeModule( - name: "Feed", - dependencies: [.target(designTarget), networkDependency] -) - - -// Creates our project using a helper function defined in ProjectDescriptionHelpers -let project = Project.app( - name: "SwiftBuddiesMain", - platform: .iOS, - additionalTargets: [ - feedModule, - mapModule, - aboutModule, - contributorsModule, - designTarget +let project = Project( + name: "Buddies", + packages: [ + .remote(url: "https://github.com/google/GoogleSignIn-iOS.git", requirement: .exact("7.0.0")), + .remote(url: "https://github.com/apple/swift-argument-parser.git", requirement: .exact("1.3.0")), + .remote(url: "https://github.com/darkbringer1/BuddiesNetwork.git", requirement: .upToNextMajor(from: .init(0, 0, 1))) ], - targetDependencies: [networkDependency] + targets: [ + .target( + name: "SwiftBuddiesIOS", + destinations: .iOS, + product: .app, + bundleId: "com.swiftbuddies.SwiftBuddiesIOS", + infoPlist: .extendingDefault( + with: [ + "CFBundleShortVersionString": "1.0", + "CFBundleVersion": "1", + "UIMainStoryboardFile": "", + "UILaunchStoryboardName": "LaunchScreen", + "CLIENT_ID": "1015261010783-dq3s025o2j6pcj81ped6nqpbiv5m1fvr.apps.googleusercontent.com", + "REVERSED_CLIENT_ID": "com.googleusercontent.apps.1015261010783-dq3s025o2j6pcj81ped6nqpbiv5m1fvr", + "NSLocationWhenInUseUsageDescription": "Your location is needed to provide location-based features.", + "CFBundleURLTypes": [ + ["CFBundleURLSchemes": ["com.googleusercontent.apps.1015261010783-dq3s025o2j6pcj81ped6nqpbiv5m1fvr"]] + ] + ] + ), + sources: ["SwiftBuddiesIOS/Sources/**"], + resources: ["SwiftBuddiesIOS/Resources/**"], + dependencies: [ + .package(product: "GoogleSignIn", type: .runtime, condition: .none), + .package(product: "BuddiesNetwork", type: .runtime, condition: .none), + .target(Modules.design.target), + .target(Modules.auth.target), + .target(Modules.onboarding.target), + .target(Modules.login.target), + .target(Modules.feed.target), + .target(Modules.map.target), + .target(Modules.profile.target), + .target(Modules.contributors.target), + .target(Modules.network.target), + .target(Modules.localization.target), + .target(Modules.core.target) + ] + ), + Modules.design.target, + Modules.auth.target, + Modules.onboarding.target, + Modules.login.target, + Modules.feed.target, + Modules.map.target, + Modules.profile.target, + Modules.contributors.target, + Modules.network.target, + Modules.localization.target, + Modules.core.target, + Modules.localicationCodegen + ] ) +enum Modules: CaseIterable { + case core + case localization + case design + case network + case auth + case onboarding + case login + case feed + case map + case profile + case contributors + + + var target: Target { + switch self { + case .core: + Target.featureTarget( + name: "Core", + productName: "Core", + dependencies: + [.target(Modules.auth.target), .target(Modules.network.target), + .package(product: "GoogleSignIn", type: .runtime, condition: .none)] +// [] + ) + case .localization: + Target.featureTarget( + name: "Localization", + productName: "Localization", + dependencies: [], + hasResources: true + ) + case .design: + Target.featureTarget( + name: "Design", + productName: "Design", + dependencies: [.target(Modules.localization.target)], + hasResources: true + ) + case .network: + Target.featureTarget( + name: "Network", + productName: "Network", + dependencies: [ + .package(product: "BuddiesNetwork", type: .runtime, condition: .none) + ] + ) + case .auth: + Target.featureTarget( + name: "Auth", + productName: "Auth", + dependencies: [ + .target(Modules.network.target), +// .target(Modules.core.target), + .package(product: "GoogleSignIn", type: .runtime, condition: .none) + ] + ) + case .onboarding: + Target.featureTarget( + name: "Onboarding", + productName: "Onboarding", + dependencies: [ + .target(Modules.design.target), + .target(Modules.core.target), + ] + ) + case .login: + Target.featureTarget( + name: "Login", + productName: "Login", + dependencies: [ + .target(Modules.design.target), + .target(Modules.auth.target), + .target(Modules.network.target), + .target(Modules.core.target), + .package(product: "GoogleSignIn", type: .runtime, condition: .none) + ] + ) + case .feed: + Target.featureTarget( + name: "Feed", + productName: "Feed", + dependencies: [ + .target(Modules.core.target), + .target(Modules.design.target) + ] + ) + case .map: + Target.featureTarget( + name: "Map", + productName: "Map", + dependencies: [ + .target(Modules.core.target), + .target(Modules.design.target) + ] + ) + case .profile: + Target.featureTarget( + name: "Profile", + productName: "Profile", + dependencies: [ + .target(Modules.design.target), + .target(Modules.auth.target), + .target(Modules.network.target), + .target(Modules.core.target), + .package(product: "GoogleSignIn", type: .runtime, condition: .none) + ] + ) + case .contributors: + Target.featureTarget( + name: "Contributors", + productName: "Contributors", + dependencies: [ + .target(Modules.core.target), + .target(Modules.design.target) + ] + ) + } + } + + static let localicationCodegen = Target.target( + name: "LocalizationCodegen", + destinations: .macOS, + product: .commandLineTool, + productName: "LocalizationCodegen", + bundleId: "com.swiftbuddies.localization", + sources: ["SwiftBuddiesIOS/Targets/ScriptsModule/LocalizationCodegen/**"], + scripts: [], + dependencies: [.package(product: "ArgumentParser", type: .runtime, condition: .none)], + coreDataModels: [], + environmentVariables: [:], + launchArguments: [], + additionalFiles: [], + buildRules: [], + mergedBinaryType: .automatic, + mergeable: false + ) +} + +//let scriptsModule = Target.target( +// name: "Scripts", +// destinations: .macOS, +// product: .staticFramework, +// productName: "Scripts", +// bundleId: "com.swiftbuddies.scripts", +// deploymentTargets: nil, +// infoPlist: nil, +// +// sources: ["SwiftBuddiesIOS/Targets/ScriptsModule/**"], +// resources: nil, +// copyFiles: nil, +// headers: nil, +// entitlements: nil, +// scripts: [], +// dependencies: [.target(localicationCodegen)], +// settings: nil, +// coreDataModels: [], +// environmentVariables: [:], +// launchArguments: [], +// additionalFiles: [], +// buildRules: [], +// mergedBinaryType: .automatic, +// mergeable: false +//) diff --git a/README.md b/README.md new file mode 100644 index 0000000..50b7d79 --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ + + +[First time here?](#project-details) + +# Buddies Community iOS App + +Welcome to the official iOS app for the Buddies Community! This project is a community-driven app built in SwiftUI & Tuist, it aims to serve the members of our vast community, from newcomers to seasoned professionals, providing networking opportunities, collaboration, and professional development. + +[![Contributors][contributors-shield]][contributors-url] +[![Forks][forks-shield]][forks-url] +[![Stargazers][stars-shield]][stars-url] +[![Issues][issues-shield]][issues-url] +[![MIT License][license-shield]][license-url] + +
+ + Logo + + +

Buddies iOS

+ +

+ Explore the docs » +
+
+ View Demo + · + Report Bug + · + Request Feature +

+
+ +# Table of Contents +1. [Getting Started](#getting-started) +2. [Installation](#installation) +3. [Features](#Features) +4. [Roadmap](#Roadmap) +5. [Contributing](#Contributing) +6. [CommunityLinks](#CommunityLinks) +7. [Contact](#Contact) + + + +### Built With + +* SwiftUI +* Tuist + + +## Getting Started + +### Prerequisites + +* Swift 5.9+ +* Xcode 15+ +* Tuist 4+ + + +### Installation + +1. Clone the repo + `git clone https://github.com/SwiftBuddiesTR/BuddiesIOS.git` +2. Install Tuist + Visit the [Tuist Installation Page](https://docs.tuist.io/documentation/tuist/installation/) +3. Generate your project + `tuist generate` + + +## Features + +Currently, the app contains four main tabs: + +- Feed: An interactive platform where members can share photos, experiences, and messaging. +- Map: Shows location of upcoming meetups and suggests nearby places ideal for work, refreshments, or casual gatherings. +- About: A detailed section providing in-depth insight into our community. +- Contributors: A nod to individuals who've contributed selflessly to the project, serving as a portfolio showcase. + + +## Roadmap + +While the app is in its early stages, future improvements and enhancements are planned. We also encourage community members to propose new features via pull requests. + + +## Contributing + +We appreciate contributions, big or small. Juniors can learn by developing features and seniors can provide feedback and guidance, keeping the development process streamlined. + +1. Clone the project +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -am 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a pull request + + +## Community Links + +* [Telegram](https://t.me/swiftbuddies) +* [LinkedIn Group](https://lnkd.in/dm2N_VQs) +* [Kommunity](https://kommunity.com/swiftbuddies) + + +## Contact +You can contribute to the project by reaching out via our Telegram group, let us know about the issues or your suggestions. + +[![Telegram Badge](https://img.shields.io/badge/Contact-Telegram-blue)](https://t.me/swiftbuddies) + +- First steps taken by + + **Doğukaan Kılıçarslan** + [@dogukaank_](https://twitter.com/dogukaank_) + + **Can Yoldaş** + [@cyns0](https://twitter.com/cyns0) + +Project Link: [https://github.com/SwiftBuddiesTR/BuddiesIOS](https://github.com/SwiftBuddiesTR/BuddiesIOS) + +## License + +This project is licensed under the MIT License - see the [LICENSE](https://github.com/SwiftBuddiesTR/BuddiesIOS/blob/main/LICENSE) file for details. + +## Acknowledgments + +A big thank you to all community members for their continuous support and contributions. Special thanks to: + +* Apple +* Tuist Contributors + +Remember, this is an Open-Source project. We look forward to your contribution, let's come together to make this app a more valuable tool for everyone in our community! + + + +## Project details + +If you're landing here for the first time, welcome to the Buddies Community iOS application project! This app, built with SwiftUI & Tuist, is the result of a collective effort driven by our community, comprising newcomers as well as seasoned professionals in iOS development. + +Buddies Community, being a platform dedicated to iOS development, aims to provide a productive environment fostering networking, collaboration, and knowledge sharing. Here, every member has an opportunity to contribute to the project, learn from the best, and get their hands dirty with real-world project experience. Our aim is to make this experience as enriching as possible, thereby helping members grow professionally and become better, more informed developers. + +It’s a space where you can share new ideas, discuss designs, learn architectural patterns, get feedback, and much more. And all of this around a practical project that continues to evolve, just like the tech industry itself. + +The iOS app offers features such as feed sharing, location suggestions, a comprehensive about section, and a contributors pane acknowledging those who've participated in developing this app. It's not just another pet project, but an application that is enriched with every contribution, making its value significantly higher than a solo endeavor. + +We invite you to explore this repository, install the app, and most importantly, contribute in whatever way you can. If you're skilled in Scala or Tuist, great! If you're not, great! We value all contributions and believe that everyone has something valuable to share. + +So dive in, explore, learn and share. We're excited to have you here and can't wait to see what you'll bring to the table. Welcome aboard! + + + +[contributors-shield]: https://img.shields.io/github/contributors/SwiftBuddiesTR/BuddiesIOS.svg?style=flat-square&color=orange +[contributors-url]: https://github.com/SwiftBuddiesTR/BuddiesIOS/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/SwiftBuddiesTR/BuddiesIOS.svg?style=flat-square&color=blue +[forks-url]: https://github.com/SwiftBuddiesTR/BuddiesIOS/network/members +[stars-shield]: https://img.shields.io/github/stars/SwiftBuddiesTR/BuddiesIOS.svg?style=flat-square&color=green +[stars-url]: https://github.com/SwiftBuddiesTR/BuddiesIOS/stargazers +[issues-shield]: https://img.shields.io/github/issues/SwiftBuddiesTR/BuddiesIOS.svg?style=flat-square&color=red +[issues-url]: https://github.com/SwiftBuddiesTR/BuddiesIOS/issues +[license-shield]: https://img.shields.io/github/license/SwiftBuddiesTR/BuddiesIOS.svg?style=flat-square&color=yellow +[license-url]: https://github.com/SwiftBuddiesTR/BuddiesIOS/blob/main/LICENSE + +[readme-top]: #buddies-community-ios-app diff --git a/SwiftBuddiesIOS/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/SwiftBuddiesIOS/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/SwiftBuddiesIOS/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SwiftBuddiesIOS/Resources/Assets.xcassets/AdaptiveColor.colorset/Contents.json b/SwiftBuddiesIOS/Resources/Assets.xcassets/AdaptiveColor.colorset/Contents.json new file mode 100644 index 0000000..d890719 --- /dev/null +++ b/SwiftBuddiesIOS/Resources/Assets.xcassets/AdaptiveColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SwiftBuddiesIOS/Resources/Assets.xcassets/AppIcon.appiconset/BuddiesLogo_dark.png b/SwiftBuddiesIOS/Resources/Assets.xcassets/AppIcon.appiconset/BuddiesLogo_dark.png new file mode 100644 index 0000000..57bcc7c Binary files /dev/null and b/SwiftBuddiesIOS/Resources/Assets.xcassets/AppIcon.appiconset/BuddiesLogo_dark.png differ diff --git a/SwiftBuddiesIOS/Resources/Assets.xcassets/AppIcon.appiconset/BuddiesLogo_light.png b/SwiftBuddiesIOS/Resources/Assets.xcassets/AppIcon.appiconset/BuddiesLogo_light.png new file mode 100644 index 0000000..5dc3319 Binary files /dev/null and b/SwiftBuddiesIOS/Resources/Assets.xcassets/AppIcon.appiconset/BuddiesLogo_light.png differ diff --git a/SwiftBuddiesIOS/Resources/Assets.xcassets/AppIcon.appiconset/BuddiesLogo_tinted.png b/SwiftBuddiesIOS/Resources/Assets.xcassets/AppIcon.appiconset/BuddiesLogo_tinted.png new file mode 100644 index 0000000..6c169f5 Binary files /dev/null and b/SwiftBuddiesIOS/Resources/Assets.xcassets/AppIcon.appiconset/BuddiesLogo_tinted.png differ diff --git a/SwiftBuddiesIOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/SwiftBuddiesIOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..77336bb --- /dev/null +++ b/SwiftBuddiesIOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,38 @@ +{ + "images" : [ + { + "filename" : "BuddiesLogo_light.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "BuddiesLogo_dark.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "BuddiesLogo_tinted.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Targets/SwiftBuddiesDesign/Resources/Media/LocalMedia.xcassets/Contents.json b/SwiftBuddiesIOS/Resources/Assets.xcassets/Contents.json similarity index 100% rename from Targets/SwiftBuddiesDesign/Resources/Media/LocalMedia.xcassets/Contents.json rename to SwiftBuddiesIOS/Resources/Assets.xcassets/Contents.json diff --git a/SwiftBuddiesIOS/Resources/GoogleService-Info.plist b/SwiftBuddiesIOS/Resources/GoogleService-Info.plist new file mode 100644 index 0000000..1f956a6 --- /dev/null +++ b/SwiftBuddiesIOS/Resources/GoogleService-Info.plist @@ -0,0 +1,34 @@ + + + + + CLIENT_ID + 1015261010783-dq3s025o2j6pcj81ped6nqpbiv5m1fvr.apps.googleusercontent.com + REVERSED_CLIENT_ID + com.googleusercontent.apps.1015261010783-dq3s025o2j6pcj81ped6nqpbiv5m1fvr + API_KEY + AIzaSyAUZb6hIeGqfUZ21fpMnFjOpZXv9BUUtKg + GCM_SENDER_ID + 1015261010783 + PLIST_VERSION + 1 + BUNDLE_ID + io.tuist.SwiftBuddiesIOS + PROJECT_ID + swiftbuddies-717e1 + STORAGE_BUCKET + swiftbuddies-717e1.appspot.com + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:1015261010783:ios:470fb09b3e679ad779b4ea + + \ No newline at end of file diff --git a/SwiftBuddiesIOS/Resources/LaunchScreen.storyboard b/SwiftBuddiesIOS/Resources/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/SwiftBuddiesIOS/Resources/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SwiftBuddiesIOS/Resources/Preview Content/Preview Assets.xcassets/Contents.json b/SwiftBuddiesIOS/Resources/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SwiftBuddiesIOS/Resources/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SwiftBuddiesIOS/Sources/RootView.swift b/SwiftBuddiesIOS/Sources/RootView.swift new file mode 100644 index 0000000..123f526 --- /dev/null +++ b/SwiftBuddiesIOS/Sources/RootView.swift @@ -0,0 +1,47 @@ +import SwiftUI +import Login +import Onboarding +import Design +import Auth + +struct RootView: View { + @AppStorage("isSplashScreenViewed") var isOnboardingScreenViewed : Bool = false + @State private var isLoggedOut: Bool = true + + let loggedOut = NotificationCenter.default.publisher(for: .didLoggedOut) + let loggedIn = NotificationCenter.default.publisher(for: .didLoggedIn) + + init() { } + + var body: some View { + SuitableRootView() + } + + @ViewBuilder + private func SuitableRootView() -> some View { + if isOnboardingScreenViewed { + ZStack { + if !isLoggedOut { + TabFlowView() + } else { + AuthenticationView() + } + } + .onReceive(loggedOut) { _ in + isLoggedOut = true + } + .onReceive(loggedIn) { _ in + isLoggedOut = false + } + .task { + await BuddiesAuthenticationService.shared.checkIfLoggedIn() + } + } else { + OnboardingBuilder.build() + } + } +} + +#Preview { + RootView() +} diff --git a/SwiftBuddiesIOS/Sources/SwiftBuddiesIOSApp.swift b/SwiftBuddiesIOS/Sources/SwiftBuddiesIOSApp.swift new file mode 100644 index 0000000..0890834 --- /dev/null +++ b/SwiftBuddiesIOS/Sources/SwiftBuddiesIOSApp.swift @@ -0,0 +1,38 @@ +import SwiftUI +import GoogleSignIn +import Map +import SwiftData +import Core + +@main +struct SwiftBuddiesIOSApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate + + init() { + } + + var body: some Scene { + WindowGroup { + RootView() + } + .modelContainer(for: EventModel.self) + + + } +} + +class AppDelegate: NSObject, UIApplicationDelegate { + var dependencyContainer: DependencyContainerProtocol! + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + if let clientID = Bundle.main.object(forInfoDictionaryKey: "CLIENT_ID") as? String { + let signInConfig = GIDConfiguration(clientID: clientID) + GIDSignIn.sharedInstance.configuration = signInConfig + } + + dependencyContainer = DependencyContainer.shared + dependencyContainer.build() + + return true + } +} diff --git a/SwiftBuddiesIOS/Sources/TabFlowView.swift b/SwiftBuddiesIOS/Sources/TabFlowView.swift new file mode 100644 index 0000000..3709b77 --- /dev/null +++ b/SwiftBuddiesIOS/Sources/TabFlowView.swift @@ -0,0 +1,54 @@ +import SwiftUI +import Feed +import Map +import Profile +import Contributors + +enum AppTab: Int, Identifiable { + case feed = 0 + case map + case profile + case contributors + + var id: Int { rawValue } +} + +struct TabFlowView: View { + @State var selectedTab: AppTab = .feed + + var body: some View { + TabView(selection: $selectedTab) { + FeedView() + .tabItem { + Image(systemName: "list.bullet") + Text("Feed") + } + .tag(AppTab.feed) + + MapView() + .tabItem { + Image(systemName: "map") + Text("Map") + } + .tag(AppTab.map) + + ProfileView() + .tabItem { + Image(systemName: "person.fill") + Text("Profile") + } + .tag(AppTab.profile) + + ContributorsView() + .tabItem { + Image(systemName: "person.3") + Text("Contributors") + } + .tag(AppTab.contributors) + } + } +} + +#Preview { + TabFlowView() +} diff --git a/SwiftBuddiesIOS/SwiftBuddiesIOS.entitlements b/SwiftBuddiesIOS/SwiftBuddiesIOS.entitlements new file mode 100644 index 0000000..a812db5 --- /dev/null +++ b/SwiftBuddiesIOS/SwiftBuddiesIOS.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.applesignin + + Default + + + diff --git a/SwiftBuddiesIOS/Targets/AuthModule/Sources/Extensions/Notification+Extension.swift b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Extensions/Notification+Extension.swift new file mode 100644 index 0000000..44fe9c9 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Extensions/Notification+Extension.swift @@ -0,0 +1,18 @@ +// +// Notification+Extension.swift +// SwiftBuddiesIOS +// +// Created by dogukaan on 17.09.2024. +// + +import Foundation + +public extension Notification.Name { + static let signOutNotification = Notification.Name("SignOutNotification") +} + +public extension Notification.Name { + static let didLoggedIn = Notification.Name("didLoggedIn") + static let didLoggedOut = Notification.Name("didLoggedOut") +} + diff --git a/SwiftBuddiesIOS/Targets/AuthModule/Sources/Helpers/AuthProvider.swift b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Helpers/AuthProvider.swift new file mode 100644 index 0000000..fcc3183 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Helpers/AuthProvider.swift @@ -0,0 +1,33 @@ +import Foundation + +public protocol AuthProvider { + func signIn() async throws -> SignInRequest +} + +public class GoogleAuthenticationProvider: AuthProvider { + + public init() { } + + public func signIn() async throws -> SignInRequest { + let helper = SignInGoogleHelper() + let tokens = try await helper.signIn() + return SignInRequest( + accessToken: tokens.accessToken, + type: AuthProviderOption.google.rawValue + ) + } +} + +public class AppleAuthenticationProvider: AuthProvider { + + public init() { } + + public func signIn() async throws -> SignInRequest { + let helper = await SignInAppleHelper() + let tokens = try await helper.startSignInWithAppleFlow() + return SignInRequest( + accessToken: tokens.token, + type: AuthProviderOption.apple.rawValue + ) + } +} diff --git a/SwiftBuddiesIOS/Targets/AuthModule/Sources/Helpers/SignInAppleHelper.swift b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Helpers/SignInAppleHelper.swift new file mode 100644 index 0000000..be7b24d --- /dev/null +++ b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Helpers/SignInAppleHelper.swift @@ -0,0 +1,143 @@ +import SwiftUI +import AuthenticationServices +import CryptoKit + +public struct SignInWithAppleButtonViewRepresentable: UIViewRepresentable { + + let type: ASAuthorizationAppleIDButton.ButtonType + let style: ASAuthorizationAppleIDButton.Style + + public init(type: ASAuthorizationAppleIDButton.ButtonType, style: ASAuthorizationAppleIDButton.Style) { + self.type = type + self.style = style + } + + public func makeUIView(context: Context) -> ASAuthorizationAppleIDButton { + ASAuthorizationAppleIDButton(authorizationButtonType: type, authorizationButtonStyle: style) + } + + public func updateUIView(_ uiView: ASAuthorizationAppleIDButton, context: Context) { + + } + +} + +@MainActor +final class SignInAppleHelper: NSObject { + + private var currentNonce: String? + private var completionHandler: ((Result) -> Void)? + + init(currentNonce: String? = nil, completionHandler: ((Result) -> Void)? = nil) { + self.currentNonce = currentNonce + self.completionHandler = completionHandler + } + + public func startSignInWithAppleFlow() async throws -> SignInWithAppleResult { + try await withCheckedThrowingContinuation { continuation in + self.startSignInWithAppleFlow { result in + switch result { + case .success(let signInAppleResult): + continuation.resume(returning: signInAppleResult) + return + case .failure(let error): + continuation.resume(throwing: error) + return + } + } + } + } + + public func startSignInWithAppleFlow(completion: @escaping (Result) -> Void) { + let nonce = randomNonceString() + currentNonce = nonce + completionHandler = completion + + let appleIDProvider = ASAuthorizationAppleIDProvider() + let request = appleIDProvider.createRequest() + request.requestedScopes = [.fullName, .email] + request.nonce = sha256(nonce) + + let authorizationController = ASAuthorizationController(authorizationRequests: [request]) + authorizationController.delegate = self + authorizationController.performRequests() + } + + // Adapted from https://auth0.com/docs/api-auth/tutorials/nonce#generate-a-cryptographically-random-nonce + private func randomNonceString(length: Int = 32) -> String { + precondition(length > 0) + let charset: [Character] = + Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") + var result = "" + var remainingLength = length + + while remainingLength > 0 { + let randoms: [UInt8] = (0 ..< 16).map { _ in + var random: UInt8 = 0 + let errorCode = SecRandomCopyBytes(kSecRandomDefault, 1, &random) + if errorCode != errSecSuccess { + fatalError( + "Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)" + ) + } + return random + } + + randoms.forEach { random in + if remainingLength == 0 { + return + } + + if random < charset.count { + result.append(charset[Int(random)]) + remainingLength -= 1 + } + } + } + + return result + } + + @available(iOS 13, *) + private func sha256(_ input: String) -> String { + let inputData = Data(input.utf8) + let hashedData = SHA256.hash(data: inputData) + let hashString = hashedData.compactMap { + String(format: "%02x", $0) + }.joined() + + return hashString + } + +} + +extension SignInAppleHelper: ASAuthorizationControllerDelegate { + + public func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + guard + let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential, + let appleIDToken = appleIDCredential.identityToken, + let idTokenString = String(data: appleIDToken, encoding: .utf8), + let nonce = currentNonce else { + completionHandler?(.failure(URLError(.badServerResponse))) + return + } + let name = appleIDCredential.fullName?.givenName + let email = appleIDCredential.email + let tokens = SignInWithAppleResult(token: idTokenString, nonce: nonce, name: name, email: email) + completionHandler?(.success(tokens)) + } + + public func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + debugPrint("Sign in with Apple errored: \(error)") + completionHandler?(.failure(URLError(.cannotFindHost))) + } + +} + +extension UIViewController: ASAuthorizationControllerPresentationContextProviding { + + public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + return self.view.window! + } +} diff --git a/SwiftBuddiesIOS/Targets/AuthModule/Sources/Helpers/SignInGoogleHelper.swift b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Helpers/SignInGoogleHelper.swift new file mode 100644 index 0000000..eabd930 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Helpers/SignInGoogleHelper.swift @@ -0,0 +1,26 @@ +import Foundation +import GoogleSignIn + +final class SignInGoogleHelper { + + @MainActor + public func signIn() async throws -> SignInWithGoogleResult { + guard let vc = UIApplication.shared.windows.first?.rootViewController else { + throw URLError(.cannotFindHost) + } + + let gidSignInResult = try await GIDSignIn.sharedInstance.signIn(withPresenting: vc) + + guard let idToken = gidSignInResult.user.idToken?.tokenString else { + throw URLError(.badServerResponse) + } + + let accessToken = gidSignInResult.user.accessToken.tokenString + let name = gidSignInResult.user.profile?.name + let email = gidSignInResult.user.profile?.email + + let tokens = SignInWithGoogleResult(idToken: idToken, accessToken: accessToken, name: name, email: email) + return tokens + } + +} diff --git a/SwiftBuddiesIOS/Targets/AuthModule/Sources/Managers/AuthManager/AuthenticationManager.swift b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Managers/AuthManager/AuthenticationManager.swift new file mode 100644 index 0000000..6469a0c --- /dev/null +++ b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Managers/AuthManager/AuthenticationManager.swift @@ -0,0 +1,45 @@ +import Foundation + +public enum AuthProviderOption: String { + case google, apple + + var domainName: String { self.rawValue + ".com" } +} + +public final class AuthenticationManager { + public static var shared: AuthenticationManager! + private let authService: BuddiesAuthenticationService + + public init(authService: BuddiesAuthenticationService) { + self.authService = authService + } + + public func signOut() throws { + //signOut + KeychainManager.shared.delete(.accessToken) + Task { + await authService.logout() + } + } +} + +// MARK: SIGN IN SSO +public protocol AuthWithSSOProtocol { + func signIn(provider: AuthProviderOption) async throws +} + +extension AuthenticationManager: AuthWithSSOProtocol { + public func signIn(provider: AuthProviderOption) async throws { + let authProvider: AuthProvider = switch provider { + case .google: + GoogleAuthenticationProvider() + case .apple: + AppleAuthenticationProvider() + } + + let credentials = try await authProvider.signIn() + debugPrint(credentials) + await authService.registerUser(signInRequest: .init(accessToken: credentials.accessToken, type: credentials.type)) +// await BuddiesAuthentication.shared.registerUser(signInRequest: credentials) + } +} diff --git a/SwiftBuddiesIOS/Targets/AuthModule/Sources/Managers/BuddiesAuthenticationService/BuddiesAuthenticationService.swift b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Managers/BuddiesAuthenticationService/BuddiesAuthenticationService.swift new file mode 100644 index 0000000..8b07cfa --- /dev/null +++ b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Managers/BuddiesAuthenticationService/BuddiesAuthenticationService.swift @@ -0,0 +1,88 @@ +// +// BuddiesAuthenticationService.swift +// SwiftBuddiesIOS +// +// Created by dogukaan on 17.09.2024. +// + +import Foundation +import Network +import BuddiesNetwork + +// Sends the credentials from SSOs to the buddies backend +public final class BuddiesAuthenticationService { + public static var shared: BuddiesAuthenticationService! + + private let notificationCenter: NotificationCenter + private let apiClient: BuddiesClient + + public init(notificationCenter: NotificationCenter, apiClient: BuddiesClient) { + self.notificationCenter = notificationCenter + self.apiClient = apiClient + } + + public func logout() async { + await loginState() + } + + public func registerUser(signInRequest: SignInRequest) async { + let request = RegisterRequest( + accessToken: signInRequest.accessToken, + registerType: signInRequest.type + ) + + do { + let data: RegisterRequest.Data = try await apiClient.perform( + request, + cachePolicy: .fetchIgnoringCacheCompletely + ) + let token = data.token + let type = data.type + debugPrint("token: \(token), \ntype: \(type)") + await loginState(token: token) + } catch { + debugPrint(error) + await loginState() + } + } + + public func checkIfLoggedIn() async { + if let token = KeychainManager.shared.get(key: .accessToken) { + await loginState(token: token) + } else { + await loginState() + } + } + + @MainActor + private func loginState(token: String? = nil) async { + if let token { + KeychainManager.shared.save(key: .accessToken, value: token) + notificationCenter.post(name: .didLoggedIn, object: nil) + } else { + KeychainManager.shared.delete(.accessToken) + notificationCenter.post(name: .didLoggedOut, object: nil) + } + } +} + +// MARK: - RegisterRequest +struct RegisterRequest: Requestable { + func httpProperties() -> BuddiesNetwork.HTTPOperation.HTTPProperties { + .init( + url: APIs.Login.register.url(), + httpMethod: .post, + additionalHeaders: [:], + data: self + ) + } + + var accessToken: String + var registerType: String + + struct Data: Decodable { + let token: String + let type: String + } + +} diff --git a/SwiftBuddiesIOS/Targets/AuthModule/Sources/Managers/KeychainManager/KeychainManager.swift b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Managers/KeychainManager/KeychainManager.swift new file mode 100644 index 0000000..4cd2881 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Managers/KeychainManager/KeychainManager.swift @@ -0,0 +1,80 @@ +// +// KeychainManager.swift +// SwiftBuddiesIOS +// +// Created by dogukaan on 17.09.2024. +// + +import Foundation +//import SwiftUI + +public class KeychainManager { + public enum Key: String { + case accessToken + } + + public static let shared = KeychainManager() + + @discardableResult + public func save(key: KeychainManager.Key, value: String) -> Bool { + guard let data = value.data(using: .utf8) else { + debugPrint("Failed to convert value to data") + return false + } + + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key.rawValue, + kSecValueData: data + ] + + // Delete any existing item before saving + SecItemDelete(query as CFDictionary) + + let status = SecItemAdd(query as CFDictionary, nil) + + if status == errSecSuccess { + return true + } else { + debugPrint("Failed to save item: \(status)") + return false + } + } + + public func get(key: KeychainManager.Key) -> String? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key.rawValue, + kSecReturnData: kCFBooleanTrue as CFTypeRef, + kSecMatchLimit: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecSuccess, let data = result as? Data { + guard let stringValue = String(data: data, encoding: .utf8) else { + debugPrint("Failed to convert data to string") + return nil + } + return stringValue + } else { + if status != errSecItemNotFound { + debugPrint("Failed to retrieve item: \(status)") + } + return nil + } + } + + @discardableResult + public func delete(_ key: KeychainManager.Key) -> Bool { + let query = [ + kSecClass as String: kSecClassGenericPassword as String, + kSecAttrAccount as String: key.rawValue, + ] + + let status: OSStatus = SecItemDelete(query as CFDictionary) + + return status == noErr + } +} diff --git a/SwiftBuddiesIOS/Targets/AuthModule/Sources/Models/SignInRequest.swift b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Models/SignInRequest.swift new file mode 100644 index 0000000..a54a516 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Models/SignInRequest.swift @@ -0,0 +1,13 @@ +// +// SignInRequest.swift +// Auth +// +// Created by Berkay Tuncel on 15.08.2024. +// + +import Foundation + +public struct SignInRequest { + public let accessToken: String + public let type: String +} diff --git a/SwiftBuddiesIOS/Targets/AuthModule/Sources/Models/SignInResponse.swift b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Models/SignInResponse.swift new file mode 100644 index 0000000..0da0c99 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Models/SignInResponse.swift @@ -0,0 +1,13 @@ +// +// SignInResponse.swift +// Auth +// +// Created by Berkay Tuncel on 11.08.2024. +// + +import Foundation + +public struct SignInResponse: Decodable, Equatable { + public let type: String + public let token: String +} diff --git a/SwiftBuddiesIOS/Targets/AuthModule/Sources/Models/SignInWithAppleResult.swift b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Models/SignInWithAppleResult.swift new file mode 100644 index 0000000..a79737b --- /dev/null +++ b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Models/SignInWithAppleResult.swift @@ -0,0 +1,8 @@ +import Foundation + +public struct SignInWithAppleResult { + let token: String + let nonce: String + let name: String? + let email: String? +} diff --git a/SwiftBuddiesIOS/Targets/AuthModule/Sources/Models/SignInWithGoogleResult.swift b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Models/SignInWithGoogleResult.swift new file mode 100644 index 0000000..995b14c --- /dev/null +++ b/SwiftBuddiesIOS/Targets/AuthModule/Sources/Models/SignInWithGoogleResult.swift @@ -0,0 +1,8 @@ +import Foundation + +public struct SignInWithGoogleResult { + let idToken: String + let accessToken: String + let name: String? + let email: String? +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/ContributorsView.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/ContributorsView.swift new file mode 100644 index 0000000..49fff4a --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/ContributorsView.swift @@ -0,0 +1,18 @@ +import SwiftUI +import Design + +public struct ContributorsView: View { + private let module: GitHubContributorsModule + + public init() { + self.module = GitHubContributorsModule() + } + + public var body: some View { + GitHubContributorsFlow(module: module) + } +} + +#Preview { + ContributorsView() +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/Models/ContributorContribution.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/Models/ContributorContribution.swift new file mode 100644 index 0000000..2d13fc0 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/Models/ContributorContribution.swift @@ -0,0 +1,147 @@ +// +// File.swift +// SwiftBuddiesMain +// +// Created by dogukaan on 16.12.2024. +// Copyright © 2024 SwiftBuddies. All rights reserved. +// + +import Foundation + +struct ContributorContribution: Identifiable, Codable, Hashable { + static func == (lhs: ContributorContribution, rhs: ContributorContribution) -> Bool { + lhs.id == rhs.id + } + // hash method + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + + let id: String + let type: EventType + let repo: Repository + let createdAt: String? + let payload: Payload + + struct Repository: Codable { + let name: String + let url: String + } + + struct Payload: Codable { + let action: String? + let ref: String? + let description: String? + } + + enum EventType: Codable, Equatable { + case push + case pullRequest + case pullRequestReview + case pullRequestReviewComment + case pullRequestReviewThread + case issue + case issueComment + case commitComment + case create + case delete + case fork + case watch + case member + case release + case sponsorship + case gollum // Wiki events + case `public` // Repository made public + case other(String) + + private var rawValue: String { + switch self { + case .push: return "PushEvent" + case .pullRequest: return "PullRequestEvent" + case .pullRequestReview: return "PullRequestReviewEvent" + case .pullRequestReviewComment: return "PullRequestReviewCommentEvent" + case .pullRequestReviewThread: return "PullRequestReviewThreadEvent" + case .issue: return "IssuesEvent" + case .issueComment: return "IssueCommentEvent" + case .commitComment: return "CommitCommentEvent" + case .create: return "CreateEvent" + case .delete: return "DeleteEvent" + case .fork: return "ForkEvent" + case .watch: return "WatchEvent" + case .member: return "MemberEvent" + case .release: return "ReleaseEvent" + case .sponsorship: return "SponsorshipEvent" + case .gollum: return "GollumEvent" + case .public: return "PublicEvent" + case .other(let value): return value + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + + switch rawValue { + case "PushEvent": self = .push + case "PullRequestEvent": self = .pullRequest + case "PullRequestReviewEvent": self = .pullRequestReview + case "PullRequestReviewCommentEvent": self = .pullRequestReviewComment + case "PullRequestReviewThreadEvent": self = .pullRequestReviewThread + case "IssuesEvent": self = .issue + case "IssueCommentEvent": self = .issueComment + case "CommitCommentEvent": self = .commitComment + case "CreateEvent": self = .create + case "DeleteEvent": self = .delete + case "ForkEvent": self = .fork + case "WatchEvent": self = .watch + case "MemberEvent": self = .member + case "ReleaseEvent": self = .release + case "SponsorshipEvent": self = .sponsorship + case "GollumEvent": self = .gollum + case "PublicEvent": self = .public + default: + self = .other(rawValue) + print("⚠️ Unknown GitHub event type encountered: \(rawValue)") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + } + + var title: String { + switch type { + case .push: return "Pushed to \(repo.name)" + case .pullRequest: return "Pull Request in \(repo.name)" + case .pullRequestReview: return "Reviewed PR in \(repo.name)" + case .pullRequestReviewComment: return "Commented on PR in \(repo.name)" + case .pullRequestReviewThread: return "Reviewed PR thread in \(repo.name)" + case .issue: return "Issue in \(repo.name)" + case .issueComment: return "Commented on issue in \(repo.name)" + case .commitComment: return "Commented on commit in \(repo.name)" + case .create: return "Created \(payload.ref ?? "") in \(repo.name)" + case .delete: return "Deleted \(payload.ref ?? "") in \(repo.name)" + case .fork: return "Forked \(repo.name)" + case .watch: return "Starred \(repo.name)" + case .member: return "Added as collaborator to \(repo.name)" + case .release: return "Released in \(repo.name)" + case .sponsorship: return "Sponsored \(repo.name)" + case .gollum: return "Updated wiki in \(repo.name)" + case .public: return "Made \(repo.name) public" + case .other(let eventType): + return "\(eventType.replacingOccurrences(of: "Event", with: "")) in \(repo.name)" + } + } + + var description: String { + payload.description ?? "Contributed to \(repo.name)" + } + + enum CodingKeys: String, CodingKey { + case id, type, repo, payload + case createdAt = "created_at" + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/Models/ContributorStats.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/Models/ContributorStats.swift new file mode 100644 index 0000000..b0d5af1 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/Models/ContributorStats.swift @@ -0,0 +1,31 @@ +// +// File.swift +// SwiftBuddiesMain +// +// Created by dogukaan on 16.12.2024. +// Copyright © 2024 SwiftBuddies. All rights reserved. +// + +import Foundation + +struct ContributorStats: Codable { + let login: String + let id: Int + let publicRepos: Int + let followers: Int + let following: Int + let bio: String? + let company: String? + let location: String? + let name: String? + let blog: String? + let avatarURL: String + let htmlURL: String + + enum CodingKeys: String, CodingKey { + case login, id, followers, following, bio, company, location, name, blog + case publicRepos = "public_repos" + case avatarURL = "avatar_url" + case htmlURL = "html_url" + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/Models/PaginationInfo.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/Models/PaginationInfo.swift new file mode 100644 index 0000000..69c763e --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/Models/PaginationInfo.swift @@ -0,0 +1,23 @@ +import Foundation + +struct PaginationInfo { + var totalCount: Int = 0 + var itemsPerPage: Int = 30 + var currentPage: Int = 0 + var isFetching: Bool = false + + var canLoadMore: Bool { + !isFetching && (currentPage == 0 || totalCount >= (currentPage * itemsPerPage)) + } + + mutating func nextPage() { + isFetching = true + currentPage += 1 + } + + mutating func reset() { + currentPage = 0 + totalCount = 0 + isFetching = false + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/Models/RepoFilter.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/Models/RepoFilter.swift new file mode 100644 index 0000000..2852fde --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/Models/RepoFilter.swift @@ -0,0 +1,21 @@ +// +// File.swift +// SwiftBuddiesMain +// +// Created by dogukaan on 16.12.2024. +// Copyright © 2024 SwiftBuddies. All rights reserved. +// + +import Foundation + +struct RepoFilter: Identifiable, Hashable { + let id: String + let name: String + var isSelected: Bool + + init(name: String, isSelected: Bool = false) { + self.id = name // Using repo name as id since it's unique in the context + self.name = name + self.isSelected = isSelected + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/View/ContributorDetailView.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/View/ContributorDetailView.swift new file mode 100644 index 0000000..f8eb935 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/View/ContributorDetailView.swift @@ -0,0 +1,277 @@ +// +// File.swift +// SwiftBuddiesMain +// +// Created by dogukaan on 16.12.2024. +// Copyright © 2024 SwiftBuddies. All rights reserved. +// + +import SwiftUI +import Design + +struct ContributorDetailView: View { + let contributor: Contributor + @StateObject private var viewModel: ContributorDetailViewModel + + init(contributor: Contributor) { + self.contributor = contributor + _viewModel = StateObject(wrappedValue: ContributorDetailViewModel(contributor: contributor)) + } + + var body: some View { + ScrollView { + VStack(spacing: 8) { + HStack(alignment: .top) { + profileHeader + Spacer() + StatsLoadingView( + isLoading: viewModel.isStatsLoading, + content: { stats in + statsSection(stats) + } + ) + } + .frame(maxWidth: .infinity) + + if let stats = viewModel.contributorStats { + userInfoSection(stats) + .padding(.vertical, 8) + } + + ActivitiesLoadingView( + isLoading: viewModel.isActivitiesLoading, + filters: viewModel.availableRepoFilters, + onFilterToggle: viewModel.toggleRepoFilter, + onClearFilters: viewModel.clearFilters, + content: { contributions in + contributionsList(contributions) + }, + contributions: Array(viewModel.recentContributions ?? []) + ) + + ScrollPositionIndicator( + coordinateSpace: "scroll", + onReachBottom: viewModel.fetchActivities + ) + } + .padding(.horizontal, 16) + } + .navigationTitle(viewModel.contributorStats?.name ?? contributor.name) + .task(id: "fetchContributorDetails") { + await viewModel.fetchContributorDetails() + } + .coordinateSpace(name: "scroll") + .refreshable { + await viewModel.refresh() + } + } + + private func contributionsList(_ contributions: [ContributorContribution]) -> some View { + LazyVStack(spacing: 12) { + ForEach(contributions) { contribution in + ContributionRow(contribution: contribution) + } + + if viewModel.isActivitiesLoading { + ProgressView() + .padding() + } + } + .frame(maxWidth: .infinity) + } + + private var profileHeader: some View { + VStack { + if let avatarURL = contributor.avatarURL { + AsyncImage(url: avatarURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Circle() + .foregroundColor(.gray.opacity(0.3)) + } + .frame(height: 120) + .clipShape(Circle()) + .shadow(radius: 5) + } + + Text(contributor.name) + .font(.title2) + .bold() + githubLinkButton + } + .frame(width: 140) + } + + private func userInfoSection(_ stats: ContributorStats) -> some View { + VStack(alignment: .leading, spacing: 0) { + if let bio = stats.bio { + Text(bio) + .font(.subheadline) + .foregroundColor(.secondary) + } + + VStack(alignment: .leading, spacing: 8) { + if let company = stats.company { + Label(company, systemImage: "building.2") + } + if let location = stats.location { + Label(location, systemImage: "location") + } + if let blog = stats.blog { + Link(destination: URL(string: blog) ?? URL(string: "https://github.com")!) { + Label(blog, systemImage: "link") + } + } + } + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func statsSection(_ stats: ContributorStats) -> some View { + VStack(spacing: 8) { + HStack(spacing: 16) { + StatView(title: "Contributions", value: "\(contributor.contributions)") + StatView(title: "Repos", value: "\(stats.publicRepos)") + } + HStack(spacing: 16) { + StatView(title: "Followers", value: "\(stats.followers)") + StatView(title: "Following", value: "\(stats.following)") + } + } + .padding(.vertical) + } + + private var githubLinkButton: some View { + Button { + if let url = contributor.githubURL { + UIApplication.shared.open(url) + } + } label: { + Label("Open in GitHub", systemImage: "link") + .font(.footnote) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.primary) + } +} + +// Loading wrapper views +private struct StatsLoadingView: View { + let isLoading: Bool + let content: (ContributorStats) -> Content + @State private var stats: ContributorStats? + + var body: some View { + if isLoading && stats == nil { + ProgressView() + } else if let stats { + content(stats) + .transition(.opacity) + } + } +} + +private struct ActivitiesLoadingView: View { + let isLoading: Bool + let filters: [RepoFilter] + let onFilterToggle: (RepoFilter) -> Void + let onClearFilters: () -> Void + let content: ([ContributorContribution]) -> Content + let contributions: [ContributorContribution] + + var body: some View { + VStack(spacing: 16) { + // Section Header with Filter Button + HStack { + Text("Recent Activities") + .font(.title3) + .bold() + + Spacer() + + if !filters.isEmpty { + Menu { + ForEach(filters) { filter in + Button(action: { onFilterToggle(filter) }) { + HStack { + Text(filter.name) + if filter.isSelected { + Image(systemName: "checkmark") + } + } + } + } + + Divider() + + Button(role: .destructive, action: onClearFilters) { + Text("Clear Filters") + } + } label: { + HStack(spacing: 4) { + Text("Filter") + Image(systemName: "line.3.horizontal.decrease.circle") + } + .foregroundStyle(filters.contains(where: \.isSelected) ? .blue : .secondary) + } + } + } + + // Active Filters + if !filters.isEmpty && filters.contains(where: \.isSelected) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(filters.filter(\.isSelected)) { filter in + HStack(spacing: 4) { + Text(filter.name) + .font(.caption) + Button { + onFilterToggle(filter) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.caption) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(Color.blue.opacity(0.1)) + ) + .overlay( + Capsule() + .strokeBorder(Color.blue.opacity(0.3), lineWidth: 1) + ) + } + } + .padding(.horizontal, 4) + } + } + + // Content + if isLoading && contributions.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(height: 200) + } else if contributions.isEmpty { + VStack(spacing: 8) { + Image(systemName: "doc.text.image") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("No recent activities") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .frame(height: 200) + } else { + content(contributions) + .transition(.opacity) + } + } + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewComponents/ContributionRow.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewComponents/ContributionRow.swift new file mode 100644 index 0000000..93834d5 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewComponents/ContributionRow.swift @@ -0,0 +1,94 @@ +// +// File.swift +// SwiftBuddiesMain +// +// Created by dogukaan on 16.12.2024. +// Copyright © 2024 SwiftBuddies. All rights reserved. +// + +import SwiftUI + +struct ContributionRow: View { + let contribution: ContributorContribution + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + eventIcon + + VStack(alignment: .leading, spacing: 4) { + Text(contribution.title) + .font(.subheadline) + .bold() + .lineLimit(2) + + if !contribution.description.isEmpty { + Text(contribution.description) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + + Text(contribution.createdAt ?? "") + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer(minLength: 0) + } + } + .padding() + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.gray.opacity(0.1)) + ) + } + + private var eventIcon: some View { + Image(systemName: iconName) + .foregroundColor(iconColor) + } + + private var iconName: String { + switch contribution.type { + case .push: "arrow.up.circle" + case .pullRequest: "arrow.triangle.branch" + case .pullRequestReview: "checkmark.circle" + case .pullRequestReviewComment, .pullRequestReviewThread: "bubble.left" + case .issue: "exclamationmark.circle" + case .issueComment: "text.bubble" + case .commitComment: "text.bubble.fill" + case .create: "plus.circle" + case .delete: "minus.circle" + case .fork: "tuningfork" + case .watch: "star" + case .member: "person" + case .release: "tag" + case .sponsorship: "heart" + case .gollum: "book" + case .public: "lock.open" + case .other: "circle.dotted" + } + } + + private var iconColor: Color { + switch contribution.type { + case .push: .blue + case .pullRequest, .create: .green + case .pullRequestReview: .purple + case .pullRequestReviewComment, .pullRequestReviewThread: .cyan + case .issue: .orange + case .issueComment, .commitComment: .cyan + case .delete: .red + case .fork: .blue + case .watch: .yellow + case .member: .pink + case .release: .mint + case .sponsorship: .pink + case .gollum: .indigo + case .public: .green + case .other: .secondary + } + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewComponents/PaginationView.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewComponents/PaginationView.swift new file mode 100644 index 0000000..7a32f7b --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewComponents/PaginationView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct PaginationView: View { + let content: Content + let isLoading: Bool + let hasMorePages: Bool + let onLoadMore: () async -> Void + + init( + isLoading: Bool, + hasMorePages: Bool, + onLoadMore: @escaping () async -> Void, + @ViewBuilder content: () -> Content + ) { + self.content = content() + self.isLoading = isLoading + self.hasMorePages = hasMorePages + self.onLoadMore = onLoadMore + } + + var body: some View { + VStack(spacing: 8) { + content + + if hasMorePages { + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .onAppear { + guard !isLoading else { return } + Task { + await onLoadMore() + } + } + } + } + } +} \ No newline at end of file diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewComponents/RepoFilterView.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewComponents/RepoFilterView.swift new file mode 100644 index 0000000..eedc130 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewComponents/RepoFilterView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct RepoFilterView: View { + let filters: [RepoFilter] + let onFilterToggle: (RepoFilter) -> Void + let onClearFilters: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Filter by Repository") + .font(.headline) + Spacer() + + if filters.contains(where: \.isSelected) { + Button("Clear", action: onClearFilters) + .font(.subheadline) + } + } + .padding(.horizontal, 16) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(filters) { filter in + FilterChip( + title: filter.name, + isSelected: filter.isSelected + ) { + onFilterToggle(filter) + } + } + } + .padding(.horizontal, 4) + } + .contentMargins(.horizontal, 16) + + } + } +} + +private struct FilterChip: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isSelected ? Color.accentColor : Color.gray.opacity(0.1)) + .foregroundColor(isSelected ? .white : .primary) + .cornerRadius(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isSelected ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: 1) + ) + } + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewComponents/ScrollPositionIndicator.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewComponents/ScrollPositionIndicator.swift new file mode 100644 index 0000000..0c4b3f2 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewComponents/ScrollPositionIndicator.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct ScrollPositionIndicator: View { + let coordinateSpace: String + let onReachBottom: () async -> Void + + @State private var isNearBottom = false + + var body: some View { + GeometryReader { geometry in + Color.clear + .preference( + key: ScrollOffsetPreferenceKey.self, + value: geometry.frame(in: .named(coordinateSpace)).minY + ) + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in + let threshold = UIScreen.main.bounds.height * 0.7 + let isNearBottom = offset < threshold + + if isNearBottom && !self.isNearBottom { + Task { + await onReachBottom() + } + } + self.isNearBottom = isNearBottom + } + } + .frame(height: 0) + } +} + +private struct ScrollOffsetPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} \ No newline at end of file diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewComponents/StatView.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewComponents/StatView.swift new file mode 100644 index 0000000..a2f241f --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewComponents/StatView.swift @@ -0,0 +1,25 @@ +// +// File.swift +// SwiftBuddiesMain +// +// Created by dogukaan on 16.12.2024. +// Copyright © 2024 SwiftBuddies. All rights reserved. +// + +import SwiftUI + +struct StatView: View { + let title: String + let value: String + + var body: some View { + VStack { + Text(value) + .font(.callout) + .bold() + Text(title) + .font(.caption) + .foregroundColor(.secondary) + } + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewModel/ContributorDetailViewModel.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewModel/ContributorDetailViewModel.swift new file mode 100644 index 0000000..71649e0 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsDetailScene/ViewModel/ContributorDetailViewModel.swift @@ -0,0 +1,141 @@ +// +// File.swift +// SwiftBuddiesMain +// +// Created by dogukaan on 16.12.2024. +// Copyright © 2024 SwiftBuddies. All rights reserved. +// + +import Foundation +import Network +import BuddiesNetwork + +@MainActor +class ContributorDetailViewModel: ObservableObject { + @Published private(set) var contributorStats: ContributorStats? + @Published private(set) var recentContributions: Set? + @Published private(set) var isStatsLoading = false + @Published private(set) var isActivitiesLoading = false + @Published private(set) var error: Error? + @Published var availableRepoFilters: [RepoFilter] = [] + + private var allContributions: Set = [] + private var paginationInfo = PaginationInfo() + + var canLoadMore: Bool { + paginationInfo.canLoadMore + } + + private let contributor: Contributor + private let client: BuddiesClient + + init(contributor: Contributor) { + self.contributor = contributor + let interceptorProvider = GitHubInterceptorProvider( + client: URLSessionClient(sessionConfiguration: .default) + ) + + self.client = BuddiesClient( + networkTransporter: BuddiesRequestChainNetworkTransport.getChainNetworkTransport( + interceptorProvider: interceptorProvider + ) + ) + } + + func fetchContributorDetails() async { + defer { + isStatsLoading = false + isActivitiesLoading = false + paginationInfo.isFetching = false + } + + isStatsLoading = true + isActivitiesLoading = true + + await fetchStats() + await fetchActivities() + } + + private func fetchStats() async { + let request = ContributorStatsRequest(username: contributor.name) + + do { + let data = try await client.perform(request) + self.contributorStats = data + } catch { + self.error = error + } + } + + func fetchActivities() async { + guard canLoadMore else { return } + + paginationInfo.nextPage() + isActivitiesLoading = true + paginationInfo.isFetching = true + + defer { + isActivitiesLoading = false + } + + do { + var request = ContributorActivitiesRequest(username: contributor.name) + request.page = paginationInfo.currentPage + request.per_page = paginationInfo.itemsPerPage + + for try await newContributions in client.watch(request, cachePolicy: .returnCacheDataAndFetch) { + if newContributions.isEmpty { + paginationInfo.totalCount = allContributions.count + } else { + for newContribution in newContributions { + allContributions.insert(newContribution) + } + paginationInfo.totalCount = allContributions.count + updateFilters(with: allContributions) + updateFilteredContributions() + } + } + } catch { + self.error = error + } + } + + func refresh() async { + paginationInfo.reset() + allContributions.removeAll() + await fetchContributorDetails() + } + + func toggleRepoFilter(_ filter: RepoFilter) { + if let index = availableRepoFilters.firstIndex(where: { $0.id == filter.id }) { + availableRepoFilters[index].isSelected.toggle() + updateFilteredContributions() + } + } + + func clearFilters() { + availableRepoFilters = availableRepoFilters.map { RepoFilter(name: $0.name, isSelected: false) } + updateFilteredContributions() + } + + private func updateFilteredContributions() { + let selectedRepos = Set(availableRepoFilters.filter(\.isSelected).map(\.name)) + + if selectedRepos.isEmpty { + recentContributions = allContributions + } else { + recentContributions = allContributions.filter { selectedRepos.contains($0.repo.name) } + } + } + + private func updateFilters(with contributions: Set) { + let uniqueRepos = Set(contributions.map { $0.repo.name }) + let newFilters = uniqueRepos.map { name in + if let existing = availableRepoFilters.first(where: { $0.name == name }) { + return existing + } + return RepoFilter(name: name) + } + availableRepoFilters = newFilters.sorted(by: { $0.name < $1.name }) + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsListScene/View/GitHubContributorsView.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsListScene/View/GitHubContributorsView.swift new file mode 100644 index 0000000..2895232 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsListScene/View/GitHubContributorsView.swift @@ -0,0 +1,48 @@ +// +// File.swift +// SwiftBuddiesMain +// +// Created by dogukaan on 16.12.2024. +// Copyright © 2024 SwiftBuddies. All rights reserved. +// + +import SwiftUI +import Design + +public struct GitHubContributorsView: View { + @StateObject private var viewModel: GitHubContributorsViewModel + + public init(viewModel: GitHubContributorsViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + public var body: some View { + Group { + if viewModel.isLoading { + ProgressView() + } else if let error = viewModel.error { + VStack { + Text("Error loading contributors") + .foregroundColor(.red) + Text(error.localizedDescription) + .font(.caption) + .foregroundColor(.secondary) + Button("Retry") { + Task { + await viewModel.fetchContributors() + } + } + .buttonStyle(.bordered) + } + } else { + List(viewModel.contributors) { contributor in + ContributorRow(contributor: contributor) + } + } + } + .navigationTitle("GitHub Contributors") + .task(id: "fetchContributors") { + await viewModel.fetchContributors() + } + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsListScene/ViewComponents/ContributorRow.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsListScene/ViewComponents/ContributorRow.swift new file mode 100644 index 0000000..4791baf --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsListScene/ViewComponents/ContributorRow.swift @@ -0,0 +1,50 @@ +// +// File.swift +// SwiftBuddiesMain +// +// Created by dogukaan on 16.12.2024. +// Copyright © 2024 SwiftBuddies. All rights reserved. +// + +import SwiftUI + +struct ContributorRow: View { + @EnvironmentObject private var coordinator: GitHubContributorsCoordinator + let contributor: Contributor + + var body: some View { + Button { + coordinator.push(.detail(contributor)) + } label: { + HStack { + if let avatarURL = contributor.avatarURL { + AsyncImage(url: avatarURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + } placeholder: { + Circle() + .foregroundColor(.gray.opacity(0.3)) + } + .frame(width: 50, height: 50) + .clipShape(Circle()) + } + + VStack(alignment: .leading) { + Text(contributor.name) + .font(.headline) + Text("\(contributor.contributions) contributions") + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + } + } + .buttonStyle(.plain) + .padding(.vertical, 4) + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsListScene/ViewModel/GitHubContributorsViewModel.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsListScene/ViewModel/GitHubContributorsViewModel.swift new file mode 100644 index 0000000..df0bc13 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/ContributorsListScene/ViewModel/GitHubContributorsViewModel.swift @@ -0,0 +1,68 @@ +import Foundation +import Network +import BuddiesNetwork + +public class GitHubContributorsViewModel: ObservableObject { + @Published private(set) var contributors: [Contributor] = [] + @Published private(set) var isLoading = false + @Published private(set) var error: Error? + + private let client: BuddiesClient + + init(client: BuddiesClient) { + self.client = client + } + + @MainActor + func fetchContributors() async { + defer { + isLoading = false + } + isLoading = true + error = nil + let request = ContributorsRequest() + do { + for try await result in client.watch(request, cachePolicy: .returnCacheDataAndFetch) { + contributors = result.map { contributor in + Contributor( + id: String(contributor.id), + name: contributor.login, + avatarURL: URL(string: contributor.avatarURL), + githubURL: URL(string: contributor.htmlURL), + contributions: contributor.contributions + ) + } + } + } catch { + self.error = error + } + } +} + +// MARK: - ContributorsRequest +struct ContributorsRequest: Requestable { + typealias Data = [GitHubContributorResponse] + + struct GitHubContributorResponse: Codable { + let login: String + let id: Int + let avatarURL: String + let htmlURL: String + let contributions: Int + + enum CodingKeys: String, CodingKey { + case login + case id + case avatarURL = "avatar_url" + case htmlURL = "html_url" + case contributions + } + } + func httpProperties() -> HTTPOperation.HTTPProperties { + .init( + url: APIs.GitHub.contributors.url(.github), + httpMethod: .get, + data: self + ) + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/GitHubContributorsModule.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/GitHubContributorsModule.swift new file mode 100644 index 0000000..e819c12 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/GitHubContributorsModule.swift @@ -0,0 +1,43 @@ +// +// File.swift +// SwiftBuddiesMain +// +// Created by dogukaan on 16.12.2024. +// Copyright © 2024 SwiftBuddies. All rights reserved. +// + +import SwiftUI +import BuddiesNetwork +import Network + +public struct GitHubContributorsModule: ContributorsModuleProtocol { + private let sessionConfiguration: URLSessionConfiguration + + public init(sessionConfiguration: URLSessionConfiguration = .default) { + self.sessionConfiguration = sessionConfiguration + } + + @MainActor + public func makeContributorsView() -> GitHubContributorsView { + let viewModel = makeViewModel() + return GitHubContributorsView(viewModel: viewModel) + } + + @MainActor + private func makeViewModel() -> GitHubContributorsViewModel { + let client = makeNetworkClient() + return GitHubContributorsViewModel(client: client) + } + + private func makeNetworkClient() -> BuddiesClient { + let interceptorProvider = GitHubInterceptorProvider( + client: URLSessionClient(sessionConfiguration: sessionConfiguration) + ) + + return BuddiesClient( + networkTransporter: BuddiesRequestChainNetworkTransport.getChainNetworkTransport( + interceptorProvider: interceptorProvider + ) + ) + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/Navigation/GitHubContributorsFlow.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/Navigation/GitHubContributorsFlow.swift new file mode 100644 index 0000000..b01c4d1 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/Navigation/GitHubContributorsFlow.swift @@ -0,0 +1,50 @@ +// +// File.swift +// SwiftBuddiesMain +// +// Created by dogukaan on 16.12.2024. +// Copyright © 2024 SwiftBuddies. All rights reserved. +// + +import SwiftUI + +@MainActor +final class GitHubContributorsCoordinator: ObservableObject { + enum ContributorRoute: Hashable { + case detail(Contributor) + } + + @Published var navigationStack: [ContributorRoute] = [] + + func push(_ route: ContributorRoute) { + navigationStack.append(route) + } + + func popToRoot() { + navigationStack.removeAll() + } +} + +struct GitHubContributorsFlow: View { + @StateObject private var coordinator: GitHubContributorsCoordinator + private let module: GitHubContributorsModule + + init(module: GitHubContributorsModule = GitHubContributorsModule()) { + self._coordinator = StateObject(wrappedValue: GitHubContributorsCoordinator()) + self.module = module + } + + var body: some View { + NavigationStack(path: $coordinator.navigationStack) { + module.makeContributorsView() + .environmentObject(coordinator) + .navigationDestination(for: GitHubContributorsCoordinator.ContributorRoute.self) { route in + switch route { + case .detail(let contributor): + ContributorDetailView(contributor: contributor) + .environmentObject(coordinator) + } + } + } + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/Network/Requests/ContributorActivitiesRequest.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/Network/Requests/ContributorActivitiesRequest.swift new file mode 100644 index 0000000..02cea08 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/Network/Requests/ContributorActivitiesRequest.swift @@ -0,0 +1,24 @@ +import Foundation +import Network +import BuddiesNetwork + +struct ContributorActivitiesRequest: Requestable { + @EncoderIgnorable var username: String? + var page: Int = 1 + var per_page: Int = 30 + + typealias Data = [ContributorContribution] + + enum CodingKeys: String, CodingKey { + case page + case per_page + } + + func httpProperties() -> HTTPOperation.HTTPProperties { + .init( + url: APIs.GitHub.userActivities(username: username).url(.github), + httpMethod: .get, + data: self + ) + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/Network/Requests/ContributorStatsRequest.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/Network/Requests/ContributorStatsRequest.swift new file mode 100644 index 0000000..c2d3ee6 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/GitHub/Network/Requests/ContributorStatsRequest.swift @@ -0,0 +1,15 @@ +import Foundation +import Network +import BuddiesNetwork + +struct ContributorStatsRequest: Requestable { + @EncoderIgnorable var username: String? + typealias Data = ContributorStats + func httpProperties() -> HTTPOperation.HTTPProperties { + .init( + url: APIs.GitHub.userStats(username: username).url(.github), + httpMethod: .get, + data: self + ) + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/Models/Contributor.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/Models/Contributor.swift new file mode 100644 index 0000000..c142694 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/Models/Contributor.swift @@ -0,0 +1,31 @@ +// +// File.swift +// SwiftBuddiesMain +// +// Created by dogukaan on 16.12.2024. +// Copyright © 2024 SwiftBuddies. All rights reserved. +// + +import Foundation + +public struct Contributor: Identifiable, Equatable, Hashable { + public let id: String + public let name: String + public let avatarURL: URL? + public let githubURL: URL? + public let contributions: Int + + public init( + id: String, + name: String, + avatarURL: URL? = nil, + githubURL: URL? = nil, + contributions: Int = 0 + ) { + self.id = id + self.name = name + self.avatarURL = avatarURL + self.githubURL = githubURL + self.contributions = contributions + } +} diff --git a/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/Protocols/ContributorsModuleProtocol.swift b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/Protocols/ContributorsModuleProtocol.swift new file mode 100644 index 0000000..3cdf7d2 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/ContributorsModule/Sources/Protocols/ContributorsModuleProtocol.swift @@ -0,0 +1,15 @@ +// +// ContributorsModuleProtocol.swift +// SwiftBuddiesMain +// +// Created by dogukaan on 16.12.2024. +// Copyright © 2024 SwiftBuddies. All rights reserved. +// + +import SwiftUI + +public protocol ContributorsModuleProtocol { + associatedtype ContributorsViewType: View + + func makeContributorsView() -> ContributorsViewType +} diff --git a/SwiftBuddiesIOS/Targets/CoreModule/Sources/BuddiesDependencies/DependencyContainer.swift b/SwiftBuddiesIOS/Targets/CoreModule/Sources/BuddiesDependencies/DependencyContainer.swift new file mode 100644 index 0000000..26d404d --- /dev/null +++ b/SwiftBuddiesIOS/Targets/CoreModule/Sources/BuddiesDependencies/DependencyContainer.swift @@ -0,0 +1,77 @@ +// +// DependencyContainer.swift +// SwiftBuddiesIOS +// +// Created by dogukaan on 26.09.2024. +// + +import SwiftUI +import Auth +import Network +//import Core + +@preconcurrency public final class DependencyContainer: DependencyContainerProtocol { + enum Error: String, LocalizedError { + case productNotFound + + var errorDescription: String { rawValue } + } + + + public static var shared: DependencyContainer = .init() + + let buddiesNetwork = DependencyKey() + let buddiesAuthenticator = DependencyKey() + let authManager = DependencyKey() + + private init() { } + + private var builtDependencies: [String: Any] = [:] + + private func register(_ value: Product) { + let name = String(reflecting: value) + builtDependencies[name] = value + } + + public func get(_ dependencyKey: DependencyKey) throws -> Product { + guard let product = builtDependencies[dependencyKey.name] as? Product else { + throw Error.productNotFound + } + return product + } + + @MainActor + public func build() { + let accessToken: (() -> String?) = { + KeychainManager.shared.get(key: .accessToken) + } + + let buddiesInterceptorProvider = BuddiesInterceptorProvider( + client: .init( + sessionConfiguration: .default + ), + currentToken: accessToken + ) + + let buddiesChainNetworkTransport = BuddiesRequestChainNetworkTransport.getChainNetworkTransport( + interceptorProvider: buddiesInterceptorProvider + ) + + let buddiesClient = BuddiesClient( + networkTransporter: buddiesChainNetworkTransport + ) + register(buddiesClient) + BuddiesClient.shared = buddiesClient + + let buddiesAuthService = BuddiesAuthenticationService( + notificationCenter: .default, + apiClient: .shared + ) + register(buddiesAuthService) + BuddiesAuthenticationService.shared = buddiesAuthService + + let authenticationManager = AuthenticationManager(authService: BuddiesAuthenticationService.shared) + AuthenticationManager.shared = authenticationManager + register(authenticationManager) + } +} diff --git a/SwiftBuddiesIOS/Targets/CoreModule/Sources/BuddiesDependencies/DependencyContainerProtocol.swift b/SwiftBuddiesIOS/Targets/CoreModule/Sources/BuddiesDependencies/DependencyContainerProtocol.swift new file mode 100644 index 0000000..83eaabd --- /dev/null +++ b/SwiftBuddiesIOS/Targets/CoreModule/Sources/BuddiesDependencies/DependencyContainerProtocol.swift @@ -0,0 +1,18 @@ +// +// DependencyContainerProtocol.swift +// SwiftBuddiesIOS +// +// Created by dogukaan on 26.09.2024. +// + +import Foundation + +public protocol DependencyContainerProtocol { + static var shared: Self { get } + + func get( + _ dependencyKey: DependencyKey + ) throws -> Product + + func build() +} diff --git a/SwiftBuddiesIOS/Targets/CoreModule/Sources/BuddiesDependencies/DependencyWrapper.swift b/SwiftBuddiesIOS/Targets/CoreModule/Sources/BuddiesDependencies/DependencyWrapper.swift new file mode 100644 index 0000000..3b22c55 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/CoreModule/Sources/BuddiesDependencies/DependencyWrapper.swift @@ -0,0 +1,53 @@ +// +// Dependency.swift +// SwiftBuddiesIOS +// +// Created by dogukaan on 26.09.2024. +// + +import Foundation + +public struct DependencyKey { + public let name: String + + init(name: String) { + self.name = name + } + + public init(type: Product.Type = Product.self) { + var name = String(reflecting: type) + // trimFoundationPrefix(&name) + self.init(name: name) + } + + public init(typeOf product: Product) { + self.init(type: type(of: product)) + } +} + +@propertyWrapper +public struct Dependency

{ + public var wrappedValue: P { + do { + return try tryGet() + } catch { + assertionFailure(error.localizedDescription) + preconditionFailure(error.localizedDescription) + } + } + + public func tryGet() throws -> P { + try container().get(key) + } + + let container: () -> DependencyContainerProtocol + let key: DependencyKey

+ + public init( + container: @autoclosure @escaping () -> DependencyContainerProtocol = DependencyContainer.shared, + _ keyPath: KeyPath> + ) { + self.container = container + self.key = DependencyKey

() + } +} diff --git a/SwiftBuddiesIOS/Targets/CoreModule/Sources/Core.swift b/SwiftBuddiesIOS/Targets/CoreModule/Sources/Core.swift new file mode 100644 index 0000000..f9565b4 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/CoreModule/Sources/Core.swift @@ -0,0 +1,3 @@ +class Hello { + +} diff --git a/Targets/SwiftBuddiesDesign/Resources/Media/LocalMedia.swift b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.swift similarity index 100% rename from Targets/SwiftBuddiesDesign/Resources/Media/LocalMedia.swift rename to SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.swift diff --git a/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Contents.json b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Login/Contents.json b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Login/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Login/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Login/LoginStrokeColor.colorset/Contents.json b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Login/LoginStrokeColor.colorset/Contents.json new file mode 100644 index 0000000..de07e03 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Login/LoginStrokeColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x29", + "green" : "0x9A", + "red" : "0xE8" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Login/SwiftBuddiesImage.imageset/Contents.json b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Login/SwiftBuddiesImage.imageset/Contents.json new file mode 100644 index 0000000..e45e809 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Login/SwiftBuddiesImage.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "swiftBuddiesImage.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Login/SwiftBuddiesImage.imageset/swiftBuddiesImage.pdf b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Login/SwiftBuddiesImage.imageset/swiftBuddiesImage.pdf new file mode 100644 index 0000000..96327cb Binary files /dev/null and b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Login/SwiftBuddiesImage.imageset/swiftBuddiesImage.pdf differ diff --git a/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/Contents.json b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/OnboardingBackround.colorset/Contents.json b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/OnboardingBackround.colorset/Contents.json new file mode 100644 index 0000000..4c12d57 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/OnboardingBackround.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.945", + "green" : "0.945", + "red" : "0.961" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/OnboardingBuddiesImage.imageset/Contents.json b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/OnboardingBuddiesImage.imageset/Contents.json new file mode 100644 index 0000000..6f68728 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/OnboardingBuddiesImage.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "OnboardingBuddiesImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/OnboardingBuddiesImage.imageset/OnboardingBuddiesImage.png b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/OnboardingBuddiesImage.imageset/OnboardingBuddiesImage.png new file mode 100644 index 0000000..ba8126c Binary files /dev/null and b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/OnboardingBuddiesImage.imageset/OnboardingBuddiesImage.png differ diff --git a/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/OnboardingWelcomeImage.imageset/Contents.json b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/OnboardingWelcomeImage.imageset/Contents.json new file mode 100644 index 0000000..35ff802 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/OnboardingWelcomeImage.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "onboardingWelcomeImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/OnboardingWelcomeImage.imageset/onboardingWelcomeImage.png b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/OnboardingWelcomeImage.imageset/onboardingWelcomeImage.png new file mode 100644 index 0000000..344a75f Binary files /dev/null and b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/Onboarding/OnboardingWelcomeImage.imageset/onboardingWelcomeImage.png differ diff --git a/Targets/SwiftBuddiesDesign/Resources/Media/LocalMedia.xcassets/SwiftBuddiesHeader.imageset/Contents.json b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/SwiftBuddiesHeader.imageset/Contents.json similarity index 100% rename from Targets/SwiftBuddiesDesign/Resources/Media/LocalMedia.xcassets/SwiftBuddiesHeader.imageset/Contents.json rename to SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/SwiftBuddiesHeader.imageset/Contents.json diff --git a/Targets/SwiftBuddiesDesign/Resources/Media/LocalMedia.xcassets/SwiftBuddiesHeader.imageset/SwiftBuddiesHeader.jpeg b/SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/SwiftBuddiesHeader.imageset/SwiftBuddiesHeader.jpeg similarity index 100% rename from Targets/SwiftBuddiesDesign/Resources/Media/LocalMedia.xcassets/SwiftBuddiesHeader.imageset/SwiftBuddiesHeader.jpeg rename to SwiftBuddiesIOS/Targets/DesignModule/Resources/Media/LocalMedia.xcassets/SwiftBuddiesHeader.imageset/SwiftBuddiesHeader.jpeg diff --git a/Targets/SwiftBuddiesDesign/Sources/Extensions/View+Extensions.swift b/SwiftBuddiesIOS/Targets/DesignModule/Sources/Extensions/View+Extensions.swift similarity index 71% rename from Targets/SwiftBuddiesDesign/Sources/Extensions/View+Extensions.swift rename to SwiftBuddiesIOS/Targets/DesignModule/Sources/Extensions/View+Extensions.swift index af68526..897ac3f 100644 --- a/Targets/SwiftBuddiesDesign/Sources/Extensions/View+Extensions.swift +++ b/SwiftBuddiesIOS/Targets/DesignModule/Sources/Extensions/View+Extensions.swift @@ -45,4 +45,30 @@ public extension View { } } } + + func fillView(_ color: Color, horizontolPadding: CGFloat = 15, verticalPadding: CGFloat = 10) -> some View { + self + .padding(.horizontal,horizontolPadding) + .padding(.vertical,verticalPadding) + .background { color } + } + + func endTextEditing() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} + +public extension Binding where Value == Bool { + var negated: Binding { + return Binding( + get: { !self.wrappedValue }, + set: { self.wrappedValue = !$0 } + ) + } +} + +public extension View { + func withLoginButtonFormatting() -> some View { + modifier(LoginButtonViewModifier()) + } } diff --git a/SwiftBuddiesIOS/Targets/DesignModule/Sources/ViewComponents/CustomViews/BuddiesActionButton.swift b/SwiftBuddiesIOS/Targets/DesignModule/Sources/ViewComponents/CustomViews/BuddiesActionButton.swift new file mode 100644 index 0000000..20874b9 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/DesignModule/Sources/ViewComponents/CustomViews/BuddiesActionButton.swift @@ -0,0 +1,58 @@ +// +// BuddiesActionButton.swift +// Design +// +// Created by Halit Baskurt on 16.04.2024. +// + +import SwiftUI + +public struct BuddiesActionButton: View { + + public init( + label: Text, + bgColor: Color = .orange, + iconName: String = "", + clickAction: @escaping () -> Void + ) { + self.label = label + self.bgColor = bgColor + self.iconName = iconName + self.clickAction = clickAction + } + + public let label: Text + public let bgColor: Color + public var iconName: String + public let clickAction: () -> Void + + public var body: some View { + HStack { + Button(action: { clickAction() }) { + HStack { + if !iconName.isEmpty { + Image(systemName: "person") + .resizable() + .frame(width: 40,height: 40) + .foregroundStyle(.white) + } else { EmptyView() } + } + label + .font(.body.bold()) + .foregroundStyle(.white) + .frame(height: 40) + .frame(maxWidth: .infinity, alignment: .center) + } + .fillView(bgColor) + .clipShape( + .rect( + topLeadingRadius: 10, + bottomLeadingRadius: 10, + bottomTrailingRadius: 10, + topTrailingRadius: 10 + ) + ) + } + .frame(maxWidth: .infinity) + } +} diff --git a/Targets/SwiftBuddiesDesign/Sources/ViewComponents/DismissableMessage/DismissableMessage.swift b/SwiftBuddiesIOS/Targets/DesignModule/Sources/ViewComponents/DismissableMessage/DismissableMessage.swift similarity index 100% rename from Targets/SwiftBuddiesDesign/Sources/ViewComponents/DismissableMessage/DismissableMessage.swift rename to SwiftBuddiesIOS/Targets/DesignModule/Sources/ViewComponents/DismissableMessage/DismissableMessage.swift diff --git a/Targets/SwiftBuddiesDesign/Sources/ViewComponents/HeaderParallaxView/HeaderParallaxView.swift b/SwiftBuddiesIOS/Targets/DesignModule/Sources/ViewComponents/HeaderParallaxView/HeaderParallaxView.swift similarity index 100% rename from Targets/SwiftBuddiesDesign/Sources/ViewComponents/HeaderParallaxView/HeaderParallaxView.swift rename to SwiftBuddiesIOS/Targets/DesignModule/Sources/ViewComponents/HeaderParallaxView/HeaderParallaxView.swift diff --git a/SwiftBuddiesIOS/Targets/DesignModule/Sources/ViewComponents/Shapes/HalfCapsule.swift b/SwiftBuddiesIOS/Targets/DesignModule/Sources/ViewComponents/Shapes/HalfCapsule.swift new file mode 100644 index 0000000..d2fe9a1 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/DesignModule/Sources/ViewComponents/Shapes/HalfCapsule.swift @@ -0,0 +1,26 @@ +// +// HalfCapsule.swift +// Design +// +// Created by Halit Baskurt on 16.04.2024. +// + +import SwiftUI + +public struct HalfCapsule: Shape { + public init() { } + + public func path(in rect: CGRect) -> Path { + Path { path in + path.move(to: .init(x: rect.minX, y: rect.minY)) + path.addLine(to: .init(x: rect.maxX, y: rect.minY)) + path.addLine(to: .init(x: rect.maxX, y: rect.midY)) + path.addArc(center: .init(x: rect.midX, y: rect.midY), + radius: rect.height/2, + startAngle: .degrees(0), + endAngle: .degrees(180), + clockwise: false) + path.addLine(to: .init(x: rect.minX, y: rect.midY)) + } + } +} diff --git a/SwiftBuddiesIOS/Targets/DesignModule/Sources/ViewComponents/ViewModifiers/LoginButtonViewModifier.swift b/SwiftBuddiesIOS/Targets/DesignModule/Sources/ViewComponents/ViewModifiers/LoginButtonViewModifier.swift new file mode 100644 index 0000000..3cc2a42 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/DesignModule/Sources/ViewComponents/ViewModifiers/LoginButtonViewModifier.swift @@ -0,0 +1,20 @@ +// +// LoginButtonViewModifier.swift +// Design +// +// Created by Berkay Tuncel on 19.04.2024. +// + +import SwiftUI + +struct LoginButtonViewModifier: ViewModifier { + func body(content: Content) -> some View { + content + .font(.title2) + .fontWeight(.medium) + .foregroundColor(.white) + .frame(height: 55) + .frame(maxWidth: .infinity) + .background(DesignAsset.loginStrokeColor.swiftUIColor) + } +} diff --git a/Targets/SwiftBuddiesDesign/Sources/ViewExtension.swift b/SwiftBuddiesIOS/Targets/DesignModule/Sources/ViewExtension.swift similarity index 100% rename from Targets/SwiftBuddiesDesign/Sources/ViewExtension.swift rename to SwiftBuddiesIOS/Targets/DesignModule/Sources/ViewExtension.swift diff --git a/SwiftBuddiesIOS/Targets/FeedModule/Sources/Feed.swift b/SwiftBuddiesIOS/Targets/FeedModule/Sources/Feed.swift new file mode 100644 index 0000000..9079295 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/FeedModule/Sources/Feed.swift @@ -0,0 +1,9 @@ + +import SwiftUI + +public struct FeedView: View { + public init(){ } + public var body: some View { + Text("Feed") + } +} diff --git a/SwiftBuddiesIOS/Targets/LocalizationModule/Resources/Localizable.xcstrings b/SwiftBuddiesIOS/Targets/LocalizationModule/Resources/Localizable.xcstrings new file mode 100644 index 0000000..9315c3b --- /dev/null +++ b/SwiftBuddiesIOS/Targets/LocalizationModule/Resources/Localizable.xcstrings @@ -0,0 +1,72 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "onboarding.ButtonTitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next" + } + } + } + }, + "onboarding.StartButtonTitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start" + } + } + } + }, + "onboardingItem.FirstDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buddies Community, being a platform dedicated to iOS development, aims to provide a productive environment fostering networking, collaboration, and knowledge sharing. " + } + } + } + }, + "onboardingItem.FirstTitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Welcome!" + } + } + } + }, + "onboardingItem.SecondDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dive in, explore, learn and share. We're excited to have you here and can't wait to see what you'll bring to the table. " + } + } + } + }, + "onboardingItem.SecondTitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "BuddiesIOS" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/SwiftBuddiesIOS/Targets/LocalizationModule/Sources/GeneratedLocalizationStrings.swift b/SwiftBuddiesIOS/Targets/LocalizationModule/Sources/GeneratedLocalizationStrings.swift new file mode 100644 index 0000000..9fb26eb --- /dev/null +++ b/SwiftBuddiesIOS/Targets/LocalizationModule/Sources/GeneratedLocalizationStrings.swift @@ -0,0 +1,31 @@ +import SwiftUI + +@propertyWrapper +public struct LocalizedString { + public let key: String + public init(key: String) { self.key = key } + + public var wrappedValue: Text { Text(LocalizedStringKey(self.key), bundle: .module) } + public var projectedValue: LocalizedString { self } + public var localized: String { NSLocalizedString(self.key, bundle: .module, comment: "") } + public func format(_ arguments: CVarArg...) -> String { String(format: localized, arguments: arguments) } + public func callAsFunction(_ arguments: CVarArg...) -> String { String(format: localized, arguments: arguments) } +} + + +// MARK: - Localized strings keys + +public enum L { + /// Dive in, explore, learn and share. We're excited to have you here and can't wait to see what you'll bring to the table. + @LocalizedString(key: "onboardingItem.SecondDescription") public static var onboardingitem_seconddescription: Text + /// Welcome! + @LocalizedString(key: "onboardingItem.FirstTitle") public static var onboardingitem_firsttitle: Text + /// Buddies Community, being a platform dedicated to iOS development, aims to provide a productive environment fostering networking, collaboration, and knowledge sharing. + @LocalizedString(key: "onboardingItem.FirstDescription") public static var onboardingitem_firstdescription: Text + /// Start + @LocalizedString(key: "onboarding.StartButtonTitle") public static var onboarding_startbuttontitle: Text + /// BuddiesIOS + @LocalizedString(key: "onboardingItem.SecondTitle") public static var onboardingitem_secondtitle: Text + /// Next + @LocalizedString(key: "onboarding.ButtonTitle") public static var onboarding_buttontitle: Text +} diff --git a/SwiftBuddiesIOS/Targets/LoginModule/Sources/ViewModels/AuthenticationViewModel.swift b/SwiftBuddiesIOS/Targets/LoginModule/Sources/ViewModels/AuthenticationViewModel.swift new file mode 100644 index 0000000..9b0e22f --- /dev/null +++ b/SwiftBuddiesIOS/Targets/LoginModule/Sources/ViewModels/AuthenticationViewModel.swift @@ -0,0 +1,23 @@ +import Foundation +import Combine +import Auth +import Network +import BuddiesNetwork + +@MainActor +final public class AuthenticationViewModel: ObservableObject { + private let apiClient: BuddiesClient + private let authManager: AuthWithSSOProtocol +// @Dependency(\.authManager) var authManager + + public init() { + self.authManager = AuthenticationManager(authService: .shared) + self.apiClient = .shared + } + + func signIn(provider: AuthProviderOption) throws { + Task { + try await authManager.signIn(provider: provider) + } + } +} diff --git a/SwiftBuddiesIOS/Targets/LoginModule/Sources/Views/AuthenticationView.swift b/SwiftBuddiesIOS/Targets/LoginModule/Sources/Views/AuthenticationView.swift new file mode 100644 index 0000000..2b2888a --- /dev/null +++ b/SwiftBuddiesIOS/Targets/LoginModule/Sources/Views/AuthenticationView.swift @@ -0,0 +1,62 @@ +import SwiftUI +import Design +import Auth + +public struct AuthenticationView: View { + @Environment(\.colorScheme) var colorScheme + @StateObject private var viewModel: AuthenticationViewModel = .init() + + public init() { + } + + public var body: some View { + VStack(spacing: 20) { + swiftBuddies + .padding(.bottom) + + Group { + googleSignInButton + appleSignInButton + } + .clipShape(Capsule()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } +} + +extension AuthenticationView { + private var swiftBuddies: some View { + DesignAsset.swiftBuddiesImage.swiftUIImage + .resizable() + .aspectRatio(1, contentMode: .fit) + .frame(height: 250) + } + + private var googleSignInButton: some View { + Button { + do { + try viewModel.signIn(provider: .google) + } catch { + debugPrint(error) + } + } label: { + Text("Sign in with Google") + .withLoginButtonFormatting() + } + } + + private var appleSignInButton: some View { + Button { + do { + try viewModel.signIn(provider: .apple) + } catch { + debugPrint(error) + } + } label: { + SignInWithAppleButtonViewRepresentable(type: .default, style: colorScheme == .light ? .black : .white) + .allowsHitTesting(false) + .withLoginButtonFormatting() + } + } +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Extensions/ColorExtension.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Extensions/ColorExtension.swift new file mode 100644 index 0000000..2b8546f --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Extensions/ColorExtension.swift @@ -0,0 +1,35 @@ +// +// ColorExtension.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 14.09.2024. +// + +import SwiftUI + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Extensions/DateExtension.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Extensions/DateExtension.swift new file mode 100644 index 0000000..821540b --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Extensions/DateExtension.swift @@ -0,0 +1,20 @@ +// +// DateExtension.swift +// SwiftBuddiesIOS +// +// Created by Oğuzhan Abuhanoğlu on 1.08.2024. +// + +import Foundation + +extension Date { + + func toISOString() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.calendar = Calendar(identifier: .gregorian) + return formatter.string(from: self) + } + +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Managers/LocationManager.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Managers/LocationManager.swift new file mode 100644 index 0000000..458405f --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Managers/LocationManager.swift @@ -0,0 +1,70 @@ +// +// LocationManager.swift +// SwiftBuddiesIOS +// +// Created by Oğuzhan Abuhanoğlu on 14.09.2024. +// + +import Foundation +import CoreLocation +import SwiftUI +import MapKit + +class LocationManager: NSObject, CLLocationManagerDelegate, ObservableObject { + + @Published private(set) var lastKnownLocation: Coord? + + private let manager = CLLocationManager() + + override init() { + super.init() + manager.delegate = self + manager.startUpdatingLocation() + manager.desiredAccuracy = kCLLocationAccuracyBest + } + + + func startUpdatingLocation() { + manager.startUpdatingLocation() + } + + func stopUpdatingLocation() { + manager.stopUpdatingLocation() + } + + func checkLocationAuthorization() { + switch manager.authorizationStatus { + case .notDetermined: + manager.requestWhenInUseAuthorization() + + case .restricted: + debugPrint("Location restricted") + + case .denied: + debugPrint("Location denied") + + case .authorizedAlways: + debugPrint("Location authorizedAlways") + + case .authorizedWhenInUse: + debugPrint("Location authorized when in use") + if let coordinate = manager.location?.coordinate { + lastKnownLocation = Coord(lat: coordinate.latitude.magnitude, lon: coordinate.longitude.magnitude) + } + + @unknown default: + debugPrint("Location service disabled") + + } + } + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + checkLocationAuthorization() + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + if let coordinate = locations.first?.coordinate { + lastKnownLocation = Coord(lat: coordinate.latitude.magnitude, lon: coordinate.longitude.magnitude) + } + } +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Managers/MapDataManager.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Managers/MapDataManager.swift new file mode 100644 index 0000000..3f45a60 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Managers/MapDataManager.swift @@ -0,0 +1,73 @@ +// +// MapDataManager.swift +// SwiftBuddiesIOS +// +// Created by Oğuzhan Abuhanoğlu on 17.11.2024. +// + +import Foundation +import SwiftUI +import SwiftData + +// LOCAL DATA MANAGER +@MainActor +class MapDataManager { + var modelContext: ModelContext? // The optional model context value + + init() { + + } + + + func getAllEvents() -> [EventModel] { + guard let modelContext else { return [] } + do { + print("Loading all events from context...") + let fetchDescriptor = FetchDescriptor() + let events = try modelContext.fetch(fetchDescriptor) + print("Loaded events count: \(events.count)") + return events + } catch { + print("Error fethed data: \(error)") + return [] + } + + } + + + + func addUniqueItems( + events: [EventModel] + ) { + guard let modelContext else { return } + + let existingEvents = Set(getAllEvents().map { $0.id }) + debugPrint(existingEvents) + for event in events { + if !existingEvents.contains(event.id) { + modelContext.insert(event) + print("existing event name: \(event.name)") + debugPrint("Added event: \(event.id)") + print("local events count: \(events.count)") + } + } + } + + + func deleteAllEvents() { + guard let modelContext else { return } + + do { + print("Deleting all events from context...") + let fetchDescriptor = FetchDescriptor() + let events = try modelContext.fetch(fetchDescriptor) + for event in events { + modelContext.delete(event) + } + print("Deleted events count: \(events.count)") + } catch { + let errorMessage = "Error deletion data: \(error)" + print(errorMessage) + } + } +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Models/Coord.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Models/Coord.swift new file mode 100644 index 0000000..410002e --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Models/Coord.swift @@ -0,0 +1,14 @@ +// +// Coord.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 16.09.2024. +// + +import Foundation + +struct Coord: Codable, Equatable { + let lat, lon: Double + + static func == (lhs: Coord, rhs: Coord) -> Bool { lhs.lat == rhs.lat && lhs.lon == rhs.lon } +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Models/EventModel.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Models/EventModel.swift new file mode 100644 index 0000000..1bc6527 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Models/EventModel.swift @@ -0,0 +1,86 @@ +// +// Location.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 12.05.2024. + +import Foundation +import SwiftData + +@Model +public class EventModel: Identifiable { + public var id: String + var category: Category + var name: String + var aboutEvent: String + var startDate: String + var dueDate: String + var latitude: Double + var longitude: Double + + + init( + uid: String, + category: Category, + name: String, + aboutEvent: String, + startDate: String, + dueDate: String, + latitude: Double, + longitude: Double + ) { + self.id = uid + self.category = category + self.name = name + self.aboutEvent = aboutEvent + self.startDate = startDate + self.dueDate = dueDate + self.latitude = latitude + self.longitude = longitude + } +} + +struct NewEventModel: Hashable, Codable { + var category: Category + var name: String + var description: String + var startDate: String + var dueDate: String + var latitude: Double? + var longitude: Double? +} + +typealias Categories = [Category] + +public struct Category: Identifiable, Codable, Hashable { + public let id: String + let name: String + let color: String + + init(name: String, color: String) { + self.id = UUID().uuidString + self.name = name + self.color = color + } + + init(id: String, name: String, color: String) { + self.id = id + self.name = name + self.color = color + } +} + +// For previews +extension Category { + static let mock: Category = Categories.mock[1] +} + +extension Categories { + static let mock: Categories = [ + Category(name: "All", color: "#FFFFFF"), + Category(name: "Meeting", color: "#FF0000"), + Category(name: "Study Boddy", color: "#FFFF00"), + Category(name: "Places to Work", color: "#00FF00"), + Category(name: "SwiftBuddies Event", color: "#0000FF") + ] +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/NavigationCoordinator/MapNavigationCoordinator.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/NavigationCoordinator/MapNavigationCoordinator.swift new file mode 100644 index 0000000..d0f9737 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/NavigationCoordinator/MapNavigationCoordinator.swift @@ -0,0 +1,29 @@ +// +// Coordinator.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 11.07.2024. +// + +import Foundation +import SwiftUI + + +class MapNavigationCoordinator: ObservableObject { + + enum NavigationDestination: Hashable { + case mapView + case newEventView + case selectLocationMapView(NewEventModel) + } + + @Published var mapNavigationStack: [NavigationDestination] = [] + + func navigate(to destination: NavigationDestination) { + mapNavigationStack.append(destination) + } + + func popToRoot() { + mapNavigationStack.removeLast(mapNavigationStack.count) + } +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/EventDetailsView/View/EventDetailsView.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/EventDetailsView/View/EventDetailsView.swift new file mode 100644 index 0000000..8cc41ab --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/EventDetailsView/View/EventDetailsView.swift @@ -0,0 +1,109 @@ +// +// EventDetailsView.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 22.07.2024. +// + +import SwiftUI +import MapKit + +struct EventDetailsView: View { + + var event: EventModel + + var body: some View { + ScrollView { + VStack { + VStack(alignment: .leading, spacing: 16){ + titleSection + Divider() + if event.aboutEvent != "" { + descriptionSection + Divider() + } + eventDates + Divider() + mapLocation + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + + } + .background(.ultraThinMaterial) + + + + + } +} + + +#Preview { + EventDetailsView(event: EventModel(uid: UUID().uuidString,category: .init(name: "", color: ""), name: "test", aboutEvent: "test", startDate: "", dueDate: "", latitude: 12, longitude: 12)) +} + +// MARK: COMPONENTS +extension EventDetailsView { + + private var titleSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text(event.name) + .font(.largeTitle) + .fontWeight(.semibold) + Text(event.category.name) + .font(.title3) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var descriptionSection: some View { + VStack(alignment: .leading) { + Text(event.aboutEvent) + } + } + + private var eventDates: some View { + // ISO 8601 formatını çözmek için doğru formatı kullanıyoruz + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + + let displayFormatter = DateFormatter() + displayFormatter.dateStyle = .medium + displayFormatter.timeStyle = .short + + return VStack(alignment: .leading, spacing: 8) { + Text("Between:") + .font(.title2) + .fontWeight(.bold) + + if let startDate = dateFormatter.date(from: event.startDate) { + let formattedStartDate = displayFormatter.string(from: startDate) + Text(formattedStartDate) + } + + if let dueDate = dateFormatter.date(from: event.dueDate) { + let formattedDueDate = displayFormatter.string(from: dueDate) + Text(formattedDueDate) + } + } + } + + private var mapLocation: some View { + VStack { + Map(coordinateRegion: .constant(MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: event.latitude, longitude: event.longitude), span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01))), annotationItems: [event]) { event in + MapAnnotation(coordinate: CLLocationCoordinate2D(latitude: event.latitude, longitude: event.longitude)) { + AnnotationView(color: Color(hex: event.category.color)) + .shadow(radius: 10) + } + } + .allowsHitTesting(false) + .aspectRatio(1, contentMode: .fit) + .cornerRadius(30) + } + } + +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/LocationSelectionView/View/LocationSelectionView.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/LocationSelectionView/View/LocationSelectionView.swift new file mode 100644 index 0000000..e8d3ad5 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/LocationSelectionView/View/LocationSelectionView.swift @@ -0,0 +1,116 @@ +// +// SelectLocationMapView.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 10.07.2024. +// + +import SwiftUI +import MapKit +import SwiftData + +struct LocationSelectionView: View { + + @Environment(\.presentationMode) var presentationMode + @EnvironmentObject var coordinator: MapNavigationCoordinator + @StateObject var vm = LocationSelectionViewViewModel() + @State var newEvent: NewEventModel + + @State var tappedLocation: CLLocationCoordinate2D? = nil + @State private var showAlert: Bool = false + + var createdCompletion: ((String?) -> Void)? + + init( + newEvent: NewEventModel, completion: @escaping ((String?)->Void) + ) { + self.newEvent = newEvent + self.createdCompletion = completion + } + + var body: some View { + ZStack { + mapLayer + .edgesIgnoringSafeArea([.top, .leading, .trailing]) + VStack { + Spacer() + createButton + .padding() + } + .alert(isPresented: $showAlert) { + createAlert() + } + } + } + + +} + + +#Preview { + LocationSelectionView( + newEvent: .init( + category: .mock, + name: "name", + description: "about", + startDate: "start", + dueDate: "due", + latitude: 0.00, + longitude: 0.00 + ) + ) {_ in} +} + +// MARK: COMPONENTS +extension LocationSelectionView { + + private var mapLayer: some View { + + ZStack { + MapViewRepresentable(tappedLocation: $tappedLocation, searchResults: $vm.searchResults, selectedAnnotation: $vm.selectedAnnotation) + .edgesIgnoringSafeArea([.top, .leading, .trailing]) + + VStack { + SearchBar(text: $vm.searchText, onSearchButtonClicked: vm.search) + .padding() + + Spacer() + } + .padding(.top, 80) + } + } + + private var createButton: some View { + Button(action: { + if tappedLocation != nil { + newEvent.latitude = tappedLocation?.latitude + newEvent.longitude = tappedLocation?.longitude + Task { + let eventId = await vm.createEvent(event: newEvent) + print("created") + print("new event name: \(newEvent.name)") + createdCompletion?(eventId) + } + coordinator.popToRoot() + } else { + showAlert = true + } + + }) { + Text("Create") + .frame(width: UIScreen.main.bounds.width - 64, height: 55) + .padding(.horizontal) + .background(RoundedRectangle(cornerRadius: 10).fill(Color.orange)) + .foregroundStyle(.white) + .fontWeight(.bold) + + } + } + + private func createAlert() -> Alert { + return Alert(title: Text("Ups 🧐"), + message: Text("Please specify the event location."), + dismissButton: .default(Text("OK"))) + } + +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/LocationSelectionView/View/MapViewRepresentable.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/LocationSelectionView/View/MapViewRepresentable.swift new file mode 100644 index 0000000..8a6fb1a --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/LocationSelectionView/View/MapViewRepresentable.swift @@ -0,0 +1,143 @@ +// +// MapViewRepresentable.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 30.06.2024. +// +// +// +// + +import Foundation +import SwiftUI +import MapKit + +struct MapViewRepresentable: UIViewRepresentable { + + @Binding var tappedLocation: CLLocationCoordinate2D? + @Binding var searchResults: [MKMapItem] + @Binding var selectedAnnotation: MKPointAnnotation? + + class Coordinator: NSObject, MKMapViewDelegate { + var parent: MapViewRepresentable + + init(_ parent: MapViewRepresentable) { + self.parent = parent + } + + @objc func handleLongPress(gestureRecognizer: UILongPressGestureRecognizer) { + if gestureRecognizer.state == .began { + let location = gestureRecognizer.location(in: gestureRecognizer.view as? MKMapView) + if let mapView = gestureRecognizer.view as? MKMapView { + let coordinate = mapView.convert(location, toCoordinateFrom: mapView) + parent.tappedLocation = coordinate + + // Long press yapıldığında diğer tüm anotasyonları kaldır + parent.selectedAnnotation = nil + parent.searchResults.removeAll() + } + } + } + + // Anotasyona tıklama işlemini ele al + func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) { + guard let annotation = view.annotation as? MKPointAnnotation else { return } + + // Seçilen anotasyonu kaydet ve diğerlerini temizle + parent.selectedAnnotation = annotation + parent.searchResults.removeAll() + mapView.removeAnnotations(mapView.annotations) + mapView.addAnnotation(annotation) // <-- Sadece seçilen anotasyonu ekle + + // Seçilen anotasyonun konumunu tappedLocation'a aktar + parent.tappedLocation = annotation.coordinate + + print("Seçilen anotasyon: \(annotation.title ?? "Bilinmiyor")") + print("Konum: \(annotation.coordinate.latitude), \(annotation.coordinate.longitude)") + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(self) + } + + func makeUIView(context: Context) -> MKMapView { + let mapView = MKMapView() + mapView.delegate = context.coordinator + + // Long press gesture recognizer + let longPressGesture = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleLongPress(gestureRecognizer:))) + mapView.addGestureRecognizer(longPressGesture) + + // Haritayı kullanıcının konumu ile başlat + mapView.showsUserLocation = true + mapView.userTrackingMode = .follow + + return mapView + } + + + func updateUIView(_ uiView: MKMapView, context: Context) { + // Eğer yeni bir arama yapılırsa, seçilen anotasyonu temizle + if !searchResults.isEmpty { + selectedAnnotation = nil + // Arama sonuçları için haritayı odakla + zoomToSearchResults(mapView: uiView) + } + + // Eğer seçili bir anotasyon varsa sadece onu göster + if let selectedAnnotation = selectedAnnotation { + uiView.removeAnnotations(uiView.annotations) + uiView.addAnnotation(selectedAnnotation) // <-- Sadece seçili anotasyonu göster + } else { + // Eğer seçili anotasyon yoksa, arama sonuçlarını ekle + uiView.removeAnnotations(uiView.annotations) + if let location = tappedLocation { + let annotation = MKPointAnnotation() + annotation.coordinate = location + uiView.addAnnotation(annotation) + } + + // Arama sonuçlarını göster + for item in searchResults { + let annotation = MKPointAnnotation() + annotation.coordinate = item.placemark.coordinate + annotation.title = item.name + uiView.addAnnotation(annotation) + } + } + } + + // Haritanın arama sonuçlarına odaklanmasını sağlayan fonksiyon + private func zoomToSearchResults(mapView: MKMapView) { + guard !searchResults.isEmpty else { return } + + let coordinates = searchResults.map { $0.placemark.coordinate } + let region = calculateRegion(for: coordinates) + mapView.setRegion(region, animated: true) + + } + + // Koordinatlara göre region ayarla (arama sonuclarının hepsi görünsün) + private func calculateRegion(for coordinates: [CLLocationCoordinate2D]) -> MKCoordinateRegion { + let latitudes = coordinates.map { $0.latitude } + let longitudes = coordinates.map { $0.longitude } + + let minLat = latitudes.min() ?? 0 + let maxLat = latitudes.max() ?? 0 + let minLon = longitudes.min() ?? 0 + let maxLon = longitudes.max() ?? 0 + + let center = CLLocationCoordinate2D( + latitude: (minLat + maxLat) / 2, + longitude: (minLon + maxLon) / 2 + ) + + let span = MKCoordinateSpan( + latitudeDelta: (maxLat - minLat) * 1.5, + longitudeDelta: (maxLon - minLon) * 1.5 + ) + + return MKCoordinateRegion(center: center, span: span) + } +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/LocationSelectionView/View/SearchBarRepresentable.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/LocationSelectionView/View/SearchBarRepresentable.swift new file mode 100644 index 0000000..ddde6dc --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/LocationSelectionView/View/SearchBarRepresentable.swift @@ -0,0 +1,50 @@ +// +// SearchBar.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 6.10.2024. +// + +import SwiftUI +import UIKit + +struct SearchBar: UIViewRepresentable { + @Binding var text: String + var onSearchButtonClicked: () -> Void + + class Coordinator: NSObject, UISearchBarDelegate { + @Binding var text: String + var onSearchButtonClicked: () -> Void + + init(text: Binding, onSearchButtonClicked: @escaping () -> Void) { + _text = text + self.onSearchButtonClicked = onSearchButtonClicked + } + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + text = searchText + } + + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + onSearchButtonClicked() + searchBar.resignFirstResponder() + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(text: $text, onSearchButtonClicked: onSearchButtonClicked) + } + + func makeUIView(context: UIViewRepresentableContext) -> UISearchBar { + let searchBar = UISearchBar(frame: .zero) + searchBar.delegate = context.coordinator + searchBar.backgroundColor = UIColor.clear + searchBar.placeholder = "Find the best place for your event..." + searchBar.autocapitalizationType = .none + return searchBar + } + + func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext) { + uiView.text = text + } +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/LocationSelectionView/ViewModel/LocationSelectionViewViewModel.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/LocationSelectionView/ViewModel/LocationSelectionViewViewModel.swift new file mode 100644 index 0000000..05242ea --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/LocationSelectionView/ViewModel/LocationSelectionViewViewModel.swift @@ -0,0 +1,41 @@ +// +// NewEventViewViewModel.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 18.07.2024. +// + +import Foundation +import SwiftData +import MapKit + +class LocationSelectionViewViewModel: ObservableObject { + + private let mapService = MapService() + + @Published var selectedAnnotation: MKPointAnnotation? + @Published var searchText = "" + @Published var searchResults: [MKMapItem] = [] + @Published var mapRegion: MKCoordinateRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 0, longitude: 0), + span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) + ) + + + func search() { + let request = MKLocalSearch.Request() + request.naturalLanguageQuery = searchText + + let search = MKLocalSearch(request: request) + search.start { response, error in + if let response = response { + self.searchResults = response.mapItems + } + } + } + + + func createEvent(event: NewEventModel) async -> String? { + await mapService.createEvent(event: event) + } +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/MainMapView/Model/MapEventModel.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/MainMapView/Model/MapEventModel.swift new file mode 100644 index 0000000..c2f3ce6 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/MainMapView/Model/MapEventModel.swift @@ -0,0 +1,59 @@ +// +// MapEventModel.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 22.10.2024. +// + +import Foundation + +struct MapEventsResponseModel: Codable { + let count: Int? + let events: [MapEventModel]? +} + +// MARK: - Event +struct MapEventModel: Codable { + let uid: String? + let ownerUid: String? + let category: String? + let name: String? + let description: String? + let startDate: String? + let dueDate: String? + let latitude: Double? + let longitude: Double? + + enum CodingKeys: String, CodingKey { + case uid = "uid" + case ownerUid = "owner_uid" + case category = "category" + case name = "name" + case description = "description" + case startDate = "startDate" + case dueDate = "dueDate" + case latitude = "latitude" + case longitude = "longitude" + } +} + +// MARK: - Welcome +struct MapCreateEventRequestModel: Codable { + let category: String? + let name: String? + let description: String? + let startDate: Date? + let dueDate: Date? + let latitude: Double? + let longitude: Double? + + enum CodingKeys: String, CodingKey { + case category = "category" + case name = "name" + case description = "description" + case startDate = "startDate" + case dueDate = "dueDate" + case latitude = "latitude" + case longitude = "longitude" + } +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/MainMapView/Service/MapService.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/MainMapView/Service/MapService.swift new file mode 100644 index 0000000..1404224 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/MainMapView/Service/MapService.swift @@ -0,0 +1,139 @@ +// +// MapService.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 2.12.2024. +// + +import Foundation +import BuddiesNetwork +import Network + +class MapService { + + private let apiClient: BuddiesClient! + + init() { + self.apiClient = .shared + } + + func createEvent(event: NewEventModel) async -> String? { + let request = MapCreateEventRequest( + category: event.category.name, + name: event.name, + description: event.description, + startDate: event.startDate, + dueDate: event.dueDate, + latitude: event.latitude, + longitude: event.longitude + ) + + do { + let data = try await apiClient.perform(request) + print("new event created \(data)") + return data.uid + } catch { + debugPrint(error) + return nil + } + } + + + func fetchEvents() async -> [EventModel] { + let request = MapGetEventsRequest() + var fetchedEvents: [EventModel] = [] + + do { + let data = try await apiClient.perform(request) + + guard let mapEvents = data.events else { + return [] + } + + let events: [EventModel] = mapEvents.compactMap { mapEvent in + guard + let uid = mapEvent.uid, + let categoryString = mapEvent.category, + let category = Categories.mock.first(where: { $0.name == categoryString }), + let name = mapEvent.name, + let description = mapEvent.description, + let startDate = mapEvent.startDate, + let dueDate = mapEvent.dueDate, + let latitude = mapEvent.latitude, + let longitude = mapEvent.longitude + else { + return nil + } + + return EventModel( + uid: uid, + category: category, + name: name, + aboutEvent: description, + startDate: startDate, + dueDate: dueDate, + latitude: latitude, + longitude: longitude + ) + } + fetchedEvents = events + print("fetched events count: that is the counts of events in the database\(fetchedEvents.count)") + + + } catch { + debugPrint(error) + } + + return fetchedEvents + } +} + + +struct MapCreateEventRequest: Requestable { + let category: String? + let name: String? + let description: String? + let startDate: String? + let dueDate: String? + let latitude: Double? + let longitude: Double? + + enum CodingKeys: String, CodingKey { + case category = "category" + case name = "name" + case description = "description" + case startDate = "startDate" + case dueDate = "dueDate" + case latitude = "latitude" + case longitude = "longitude" + } + + struct Data: Decodable { + var uid: String? + } + + func httpProperties() -> BuddiesNetwork.HTTPOperation.HTTPProperties { + .init( + url: APIs.Map.createEvent.url(), + httpMethod: .post, + data: self + ) + } +} + + +struct MapGetEventsRequest: Requestable { + struct Data: Codable { + let count: Int? + let events: [MapEventModel]? + } + + func httpProperties() -> HTTPOperation.HTTPProperties { + .init( + url: APIs.Map.getEvents.url(), + httpMethod: .get, + data: self + ) + } +} + diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/MainMapView/View/MapView.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/MainMapView/View/MapView.swift new file mode 100644 index 0000000..666b6be --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/MainMapView/View/MapView.swift @@ -0,0 +1,246 @@ +import SwiftUI +import MapKit +import Design +import SwiftData + +public struct MapView: View { + + @StateObject var vm = MapViewModel() + @StateObject var coordinator = MapNavigationCoordinator() + @Environment(\.modelContext) private var context + + public init() {} + + public var body: some View { + NavigationStack(path: $coordinator.mapNavigationStack) { + ZStack { + mapLayer + .ignoresSafeArea(edges: [.top, .leading, .trailing]) + + VStack(alignment: .leading) { + listHeader + .padding(.top, 5) + .padding(.leading) + .padding(.trailing, 56) + categoryFilterButton + .padding(.horizontal) + Spacer() + + if !vm.categoryModalShown { + VStack { + HStack { + VStack { + + if vm.showExplanationText == true , vm.currentEvent != nil { + explanationText + } + if vm.currentEvent != nil { + learnMoreButton + .allowsHitTesting(vm.currentEvent != nil) + } + + } + .frame(maxHeight: .infinity, alignment: .bottom) + + createEventButton + .padding(.horizontal) + .frame(maxHeight: .infinity, alignment: .bottom) + } + .padding() + } + } + + } + } + .bottomSheet( + presentationDetents: [.large, .fraction(0.2), .fraction(0.9), .medium], + detentSelection: $vm.selectedDetent, + isPresented: $vm.categoryModalShown, + sheetCornerRadius: 12, + interactiveDismissDisabled: false) { + CategoryPicker( + selectedCategory: $vm.selectedCategory, + categories: vm.categories + ) + } onDismiss: { + withAnimation(.easeInOut) { + vm.filteredItems(items: vm.allEvents, selectedItems: &vm.selectedEvents) + } + } + .navigationDestination(for: MapNavigationCoordinator.NavigationDestination.self) { destination in + switch destination { + case .mapView: + MapView() + case .newEventView: + NewEventView() + case .selectLocationMapView(let event): + LocationSelectionView(newEvent: event) { eventId in + if let eventId { + debugPrint("Event Created with ID: \(eventId)") + Task { + await vm.getAllEvents() + } + } else { + debugPrint("EVENT CREATION FAILED") + } + } + } + } + } + .environmentObject(vm) + .environmentObject(coordinator) + } + + private func createAlert(text: String? = nil) -> Alert { + return Alert(title: Text("Ups 🧐"), + message: Text(text ?? "Something went wrong, please try again"), + dismissButton: .default(Text("OK"))) + } +} + + +#Preview { + MapView() +} + + +// MARK: COMPONENTS +extension MapView { + + private var mapLayer: some View { + + ZStack { + Map( + coordinateRegion: $vm.region, + showsUserLocation: true, + annotationItems: vm.selectedEvents + ) { item in + MapAnnotation( + coordinate: CLLocationCoordinate2D(latitude: item.latitude, longitude: item.longitude) + ) { + AnnotationView(color: Color(hex: item.category.color)) + .scaleEffect(vm.currentEvent == item ? 1 : 0.8) + .onTapGesture { + withAnimation(.easeInOut) { + vm.currentEvent = item + vm.showEventListView = false + } + } + .shadow(radius: 10) + } + } + } + .mapControls { + MapUserLocationButton() + .mapControlVisibility(.visible) + .padding(.top, 100) + } + .task { + await vm.getAllEvents() + } + .onAppear{ + vm.startUpdatingLocation() + vm.currentEvent = vm.selectedEvents.last + } + .onDisappear { + vm.stopUpdatingLocation() + vm.showExplanationText = false + } + } + + private var listHeader: some View { + VStack { + Button { + vm.toggleEventList() + } label: { + Text(vm.currentEvent?.name ?? "Select an event") + .font(.title2) + .fontWeight(.black) + .foregroundColor(.primary) + .frame(height: 44) + .frame(maxWidth: .infinity) + .overlay(alignment: .leading) { + Image(systemName: "arrow.down") + .font(.headline) + .foregroundColor(.primary) + .padding() + .rotationEffect(Angle(degrees: vm.showEventListView ? 180 : 0)) + } + } + if vm.showEventListView { + EventListView(events: vm.selectedEvents) + } + } + .background(.thickMaterial) + .cornerRadius(10) + .shadow(color: .black.opacity(0.3), radius: 20 ,x: 0 , y: 15) + } + + private var categoryFilterButton: some View { + VStack{ + Button(action: { + vm.categoryModalShown.toggle() + }) { + Text("Filter by Category") + .foregroundColor(.secondary) + .font(.footnote) + .padding() + } + } + .frame(height: 24) + .background(.thickMaterial) + .shadow(color: .black.opacity(0.3), radius: 20 ,x: 0 , y: 15) + .cornerRadius(30) + } + + private var learnMoreButton: some View { + NavigationLink { + if let event = vm.currentEvent { + EventDetailsView(event: event) + } + } label: { + Image(systemName: "info.circle.fill") + .foregroundColor(.white) + .padding() + .background(Color.red) + .cornerRadius(55/2) + } + .padding(.horizontal) + .frame(maxWidth: .infinity, alignment: .leading) + } + + + private var explanationText: some View { + VStack { + Text("You can click for more information about the selected event on the map.") + .font(.headline) + .foregroundStyle(.red) + .padding() + .background(Color.white) + .cornerRadius(10) + + Image(systemName: "triangle.fill") + .resizable() + .scaledToFit() + .frame(width: 10, height: 10) + .foregroundColor(Color.red) + .rotationEffect(Angle(degrees: 180)) + .offset(x: -100 , y: -11) + } + .multilineTextAlignment(.center) + } + + + private var createEventButton: some View { + Button(action: { + coordinator.navigate(to: .newEventView) + }) { + Image(systemName: "plus") + .foregroundColor(.white) + .padding() + .background(Color.orange) + .cornerRadius(55/2) + } + } + +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/MainMapView/ViewModel/MapViewModel.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/MainMapView/ViewModel/MapViewModel.swift new file mode 100644 index 0000000..696fa28 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/MainMapView/ViewModel/MapViewModel.swift @@ -0,0 +1,128 @@ +// +// MapViewViewModel.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 12.05.2024. +// + +import Foundation +import SwiftUI +import MapKit +import CoreLocation +import Combine +import BuddiesNetwork +import Network +import SwiftData + +@MainActor +class MapViewModel: ObservableObject { + + private let apiClient: BuddiesClient + private var locationManager = LocationManager() + private let mapService = MapService() + + @Published var allEvents: [EventModel] = [] + @Published var selectedEvents: [EventModel] = [] + + @Published var currentEvent: EventModel? { + didSet { + withAnimation(.easeInOut) { + setMapRegion(to: currentEvent) + } + } + } + + @Published var categoryModalShown: Bool = false + @Published var selectedCategory: Category? + @Published var selectedDetent: PresentationDetent = .fraction(0.9) + @Published var showEventListView: Bool = false + @Published var showExplanationText: Bool = true + @Published var showAlert: Bool = false + + + @Published var region : MKCoordinateRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 40, longitude: 40), + span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1) + ) + + @Published var categories: Categories + + var filteredCategories: Categories { + categories.filter { $0.name != "All" } + } + + init() { + self.categories = .mock + self.apiClient = .shared + self.selectedCategory = self.categories.first + setUserLocation() + } + + func setUserLocation(errorCompletion: (() -> Void)? = nil) { + if let location = locationManager.lastKnownLocation { + setMapRegion(to: location) + } else { + errorCompletion + } + } + + func getAllEvents() async { + allEvents = await mapService.fetchEvents() + selectedEvents = allEvents + } + + + // MARK: DATA FILTERING + private func setMapRegion(to item: EventModel?) { + guard let item else { + return + } + let coordinate = CLLocationCoordinate2D(latitude: item.latitude, longitude: item.longitude) + let span = MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) + self.region = MKCoordinateRegion(center: coordinate, span: span) + } + + + private func setMapRegion(to coord: Coord?) { + guard let coord, currentEvent == nil else { + return + } + let coordinate = CLLocationCoordinate2D(latitude: coord.lat, longitude: coord.lon) + let span = MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.05) + self.region = MKCoordinateRegion(center: coordinate, span: span) + } + + + func filteredItems(items: [EventModel], selectedItems: inout [EventModel]) { + selectedItems.removeAll() + if selectedCategory?.name == "All" { + selectedItems = items + } + + for item in items { + if selectedCategory?.name == item.category.name { + selectedItems.append(item) + } + } + + if let firstItem = selectedItems.first { + setMapRegion(to: firstItem) + } + } + + func toggleEventList() { + withAnimation(.easeInOut) { + showEventListView.toggle() + } + } + + + // MARK: LOCATİON + func stopUpdatingLocation() { + locationManager.stopUpdatingLocation() + } + + func startUpdatingLocation() { + locationManager.startUpdatingLocation() + } +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/NewEventView/View/NewEventView.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/NewEventView/View/NewEventView.swift new file mode 100644 index 0000000..4e79941 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/NewEventView/View/NewEventView.swift @@ -0,0 +1,186 @@ +// +// NewEventView.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 13.05.2024. +// + +import SwiftUI +import SwiftData + +struct NewEventView: View { + + @Environment(\.presentationMode) var presentationMode + @Environment(\.modelContext) private var context + + @EnvironmentObject var coordinator: MapNavigationCoordinator + @EnvironmentObject var mapVM: MapViewModel + @StateObject private var vm = NewEventViewViewModel() + + var body: some View { + ScrollView { + VStack(spacing: 20) { + categoryPickerMenu + nameTextfield + descriptionTextField + Divider() + datePickers + nextButton + + } + .alert(isPresented: $vm.showAlert) { + createAlert() + } + .navigationTitle("Event Details") + .navigationBarTitleDisplayMode(.large) + .padding(.top) + Spacer() + } + } +} + + + +#Preview { + NewEventView() +} + + +// MARK: COMPONENTS +extension NewEventView { + + private var categoryPickerMenu: some View { + Menu { + ForEach(mapVM.filteredCategories) { category in + Button(action: { + vm.categorySelection = category + }) { + Text(category.name.capitalized) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity) + } + } + } label: { + HStack { + Text(vm.categorySelection?.name ?? "Select a Category") + .font(.headline) + .foregroundStyle(Color("AdaptiveColor")) + .padding() + .frame(maxWidth: .infinity) + .frame(height: 55) + .background( + Color(.secondarySystemBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.primary, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) + } + } + } + + private var nameTextfield: some View { + TextField("Event name...", text: $vm.nameText) + .textInputAutocapitalization(.never) + .font(.headline) + .padding() + .frame(maxWidth: .infinity) + .frame(height: 55) + .background( + Color(.secondarySystemBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.primary, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) + } + + private var descriptionTextField: some View { + TextField("About your event...", text: $vm.descriptionText) + .font(.headline) + .padding() + .frame(maxWidth: .infinity) + .frame(height: 55) + .background( + Color(.secondarySystemBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.primary, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) + } + + private var datePickers: some View { + VStack(spacing: 20) { + DatePicker("Start Date", selection: $vm.startDate, displayedComponents: [.date]) + .font(.headline) + .padding() + .frame(maxWidth: .infinity) + .frame(height: 55) + .background( + Color(.secondarySystemBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.primary, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) + + DatePicker("Due Date", selection: $vm.dueDate, in: vm.startDate..., displayedComponents: [.date]) + .font(.headline) + .padding() + .frame(maxWidth: .infinity) + .frame(height: 55) + .background( + Color(.secondarySystemBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.primary, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) + } + } + + private var nextButton: some View { + Button(action: { + if let selection = vm.categorySelection { + let newEventModel: NewEventModel = .init( + category: selection, + name: vm.nameText, + description: vm.descriptionText, + startDate: vm.startDate.toISOString(), + dueDate: vm.dueDate.toISOString(), + latitude: nil, + longitude: nil + ) + coordinator.navigate(to: .selectLocationMapView(newEventModel)) + } else { + vm.showAlert = true + } + + }) { + Text("Next") + .frame(width: UIScreen.main.bounds.width - 64, height: 55) + .padding(.horizontal) + .background(RoundedRectangle(cornerRadius: 10).fill(Color.orange)) + .foregroundColor(.white) + .fontWeight(.bold) + } + + } + + private func createAlert() -> Alert { + return Alert(title: Text("Ups 🧐"), + message: Text("Category option can not be empty."), + dismissButton: .default(Text("OK"))) + } +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/NewEventView/ViewModel/NewEventViewViewModel.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/NewEventView/ViewModel/NewEventViewViewModel.swift new file mode 100644 index 0000000..1bd33f9 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/Scenes/NewEventView/ViewModel/NewEventViewViewModel.swift @@ -0,0 +1,20 @@ +// +// NewEventViewViewModels.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 19.07.2024. +// + +import Foundation + +final class NewEventViewViewModel: ObservableObject { + + @Published var categorySelection: Category? + @Published var nameText: String = "" + @Published var descriptionText: String = "" + @Published var adressText: String = "" + @Published var startDate: Date = Date() + @Published var dueDate: Date = Date() + + @Published var showAlert: Bool = false +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/ViewComponents/CategoryPickerSheet.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/ViewComponents/CategoryPickerSheet.swift new file mode 100644 index 0000000..5aec171 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/ViewComponents/CategoryPickerSheet.swift @@ -0,0 +1,63 @@ +// +// CategoryPickerSheet.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 5.06.2024. +// + +import SwiftUI +import SwiftData + +struct CategoryPicker: View { + + @Environment(\.presentationMode) var presentationMode + + @Binding var selectedCategory: Category? + let categories: Categories + + var body: some View { + NavigationView { + List { + ForEach(categories, id: \.self) { category in + Button(action: { + selectedCategory = category + presentationMode.wrappedValue.dismiss() + }) { + Text(category.name) + .fontWeight(.semibold) + .padding() + .frame(maxWidth: .infinity) + .background(Color.orange) + .foregroundColor(.white) + .cornerRadius(10) + .padding(.horizontal) + } + } + } + .navigationTitle("Select Category") + .navigationBarItems(trailing: backButton) + + } + } +} + +// MARK: COMPONENTS +extension CategoryPicker { + + private var backButton: some View{ + Button(action: { + //dismiss sheet + presentationMode.wrappedValue.dismiss() + }) { + Image(systemName: "xmark") + .font(.headline) + .foregroundColor(.primary) + .padding() + .background(.thinMaterial) + .cornerRadius(10) + .shadow(radius: 7) + + } + .padding(.top, 20) + } +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/ViewComponents/CustomAnnotationView.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/ViewComponents/CustomAnnotationView.swift new file mode 100644 index 0000000..a7e0c00 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/ViewComponents/CustomAnnotationView.swift @@ -0,0 +1,42 @@ +// +// CustomAnnotationView.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 2.07.2024. +// + +import SwiftUI + +struct AnnotationView: View { + + let color: Color + + var body: some View { + + VStack{ + Image(systemName: "map.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + .foregroundColor(.white) + .padding(6) + .background(color) + .clipShape(.circle) + + Image(systemName: "triangle.fill") + .resizable() + .scaledToFit() + .frame(width: 10, height: 10) + .foregroundColor(color) + .rotationEffect(Angle(degrees: 180)) + .offset(y: -11) + + } + //bu paddingi annotation yerleştirildiğinde konumu kapatmaması ve okun tam lokasyonnu göstermesi için kullandım + .padding(.bottom) + } +} + +#Preview { + AnnotationView(color: .red) +} diff --git a/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/ViewComponents/EventListView.swift b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/ViewComponents/EventListView.swift new file mode 100644 index 0000000..e4c10a6 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/MapModule/Sources/MapView/ViewComponents/EventListView.swift @@ -0,0 +1,56 @@ +// +// EventListView.swift +// Map +// +// Created by Oğuzhan Abuhanoğlu on 22.07.2024. +// + +import SwiftUI + +struct EventListView: View { + + @EnvironmentObject var vm: MapViewModel + var events: [EventModel] + + var body: some View { + List{ + ForEach(events) { event in + listRowView(event: event) + .onTapGesture { + vm.currentEvent = event + vm.toggleEventList() + } + .padding(.vertical , 4) + .listRowBackground(Color.clear) + } + } + .listStyle(PlainListStyle()) + } +} + +#Preview { + EventListView(events: [EventModel(uid: UUID().uuidString, category: .mock, name: "test", aboutEvent: "test", startDate: "", dueDate: "", latitude: 12, longitude: 12)]) +} + +extension EventListView { + + private func listRowView(event: EventModel) -> some View { + HStack{ + Image(systemName: "circle.dotted.circle") + .foregroundColor(imageColor(event: event)) + + VStack(alignment: .leading){ + Text(event.name) + .font(.headline) + Text(event.category.name) + .font(.subheadline) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + } + + private func imageColor(event: EventModel) -> Color { + Color(hex: event.category.color) + } +} diff --git a/SwiftBuddiesIOS/Targets/NetworkModule/Sources/BuddiesClient/BuddiesClient.swift b/SwiftBuddiesIOS/Targets/NetworkModule/Sources/BuddiesClient/BuddiesClient.swift new file mode 100644 index 0000000..49c5dd5 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/NetworkModule/Sources/BuddiesClient/BuddiesClient.swift @@ -0,0 +1,89 @@ +// +// BuddiesClient.swift +// SwiftBuddiesIOS +// +// Created by dogukaan on 14.09.2024. +// + +import Foundation +import BuddiesNetwork + + +final public class BuddiesClient { + private let apiClient: APIClient + + public static var shared: BuddiesClient! + + public init(networkTransporter: NetworkTransportProtocol) { + apiClient = .init(networkTransporter: networkTransporter) + } + + @discardableResult + public func perform( + _ request: Request, + dispatchQueue: DispatchQueue = .main, + cachePolicy: CachePolicy = .returnCacheDataAndFetch, + completion: @escaping HTTPResultHandler + ) -> (any Cancellable)? { + apiClient.perform( + request, + dispatchQueue: dispatchQueue, + cachePolicy: cachePolicy, + completion: completion + ) + } + + public func watch( + _ request: Request, + cachePolicy: CachePolicy = .returnCacheDataAndFetch, + dispatchQueue: DispatchQueue = .main + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = perform( + request, + dispatchQueue: dispatchQueue, + cachePolicy: cachePolicy) { result in + switch result { + case .success(let httpResult): + continuation.yield(httpResult.data) + + if httpResult.isFinalForCachePolicy(policy: cachePolicy) { + continuation.finish() + } + case .failure(let error): + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable termination in + task?.cancel() + } + } + } + + @discardableResult + public func perform( + _ request: Request, + cachePolicy: CachePolicy = .returnCacheDataElseFetch, + dispatchQueue: DispatchQueue = .main + ) async throws -> Request.Data { + try await apiClient.perform(request, cachePolicy: cachePolicy, dispatchQueue: dispatchQueue) + } +} + +extension HTTPResult { + func isFinalForCachePolicy(policy: CachePolicy) -> Bool { + switch policy { + case .returnCacheDataElseFetch: + return true + case .fetchIgnoringCacheData: + return source == .server + case .fetchIgnoringCacheCompletely: + return source == .server + case .returnCacheDataDontFetch: + return source == .cache + case .returnCacheDataAndFetch: + return source == .server + } + } +} diff --git a/SwiftBuddiesIOS/Targets/NetworkModule/Sources/EndpointManager/EndpointManager.swift b/SwiftBuddiesIOS/Targets/NetworkModule/Sources/EndpointManager/EndpointManager.swift new file mode 100644 index 0000000..1890ad3 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/NetworkModule/Sources/EndpointManager/EndpointManager.swift @@ -0,0 +1,117 @@ +// +// EndpointManager.swift +// SwiftBuddiesIOS +// +// Created by dogukaan on 14.09.2024. +// + +import Foundation + +// APIs.Map.getEvents.url(), + +public enum APIs { + /// if you need to add a new endpoint see the example below + public enum Login: Endpoint { + case register + + public var value: String { + switch self { + case .register: "register" + } + } + } + + public enum Map: Endpoint { + case getEvents + case createEvent + + public var value: String { + switch self { + case .getEvents: + "getEvents" + case .createEvent: + "createEvent" + } + } + } + + public enum GitHub: Endpoint { + case contributors + case userStats(username: String?) + case userActivities(username: String?) + case userEvents(username: String?) + case userRepos(username: String?) + + public var value: String { + switch self { + case .contributors: + "repos/SwiftBuddiesTR/BuddiesIOS/contributors" + case .userStats(let username): + "users/\(username ?? "")" + case .userActivities(let username): + "users/\(username ?? "")/events/public" + case .userEvents(let username): + "users/\(username ?? "")/received_events" + case .userRepos(let username): + "users/\(username ?? "")/repos" + } + } + } +} + +extension Endpoint { + /// Use this function to create an URL for network requests. + /// - Parameter host: Host that which base url to be used for the request. + /// - Returns: Returns URL with provided endpoint and selected Host. + /** + An example use scenario: + + let url: URL = APIs.Claim.uploadFile.url(.prod) + + */ + public func url(_ host: Hosts = .qa) -> URL { + host.env.url(path: self) + } + +} + +protocol Host { + static var baseUrl: URL { get } +} + +// swiftlint: disable all +public enum Hosts { + struct Prod: Host { + static let baseUrl: URL = URL(string: "https://swiftbuddies.vercel.app/api/")! + } + + struct Qa: Host { + static let baseUrl: URL = URL(string: "https://swiftbuddies.vercel.app/api/")! + } + + struct GitHub: Host { + static let baseUrl: URL = URL(string: "https://api.github.com/")! + } + + case prod + case qa + case github + + var env: Host { + switch self { + case .prod: Prod() + case .qa: Qa() + case .github: GitHub() + } + } +} + +fileprivate extension Host { + func url(path: any Endpoint) -> URL { + Self.baseUrl.appending(path: path.value) + } +} + +public protocol Endpoint { + var value: String { get } +} diff --git a/SwiftBuddiesIOS/Targets/NetworkModule/Sources/InterceptorProvider/BuddiesInterceptorProvider.swift b/SwiftBuddiesIOS/Targets/NetworkModule/Sources/InterceptorProvider/BuddiesInterceptorProvider.swift new file mode 100644 index 0000000..e634897 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/NetworkModule/Sources/InterceptorProvider/BuddiesInterceptorProvider.swift @@ -0,0 +1,107 @@ +// +// BuddiesInterceptorProvider.swift +// SwiftBuddiesIOS +// +// Created by dogukaan on 14.09.2024. +// + +import Foundation +import BuddiesNetwork + +public final class BuddiesInterceptorProvider: InterceptorProvider { + let client: URLSessionClient + var cacheStore: any CacheStore + + public init(client: URLSessionClient, + cacheStore: any CacheStore = URLCacheStore(), + currentToken: @escaping (() -> String?)) { + self.client = client + self.cacheStore = cacheStore + self.currentToken = currentToken + } + + public var currentToken: () -> String? + + public func interceptors(for operation: some HTTPOperation) -> [Interceptor] { + [ + MaxRetryInterceptor(maxRetry: 3), + CacheReadInterceptor(store: cacheStore), + BuddiesTokenProviderInterceptor(currentToken: currentToken), + NetworkFetchInterceptor(client: client), + BuddiesJSONDecodingInterceptor(), + CacheWriteInterceptor(store: cacheStore) + ] + } + + public func additionalErrorHandler(for request: some Requestable) -> (any ChainErrorHandler)? { + AuthenticationErrorHandler() + } +} + +class AuthenticationErrorHandler: ChainErrorHandler { + func handleError( + error: any Error, + chain: any RequestChain, + operation: HTTPOperation, + response: HTTPResponse?, + completion: @escaping HTTPResultHandler + ) where Request: Requestable { + if response?.httpResponse.statusCode == 401 { + Task { @MainActor in +// try await Authenticator.shared.logout() + // TODO: Auto renew token request + chain.cancel() + } + } else { + completion(.failure(error)) + } + } +} + +public final class BuddiesRequestChainNetworkTransport { + public static func getChainNetworkTransport( + interceptorProvider: some InterceptorProvider + ) -> any NetworkTransportProtocol { + return DefaultRequestChainNetworkTransport(interceptorProvider: interceptorProvider) + } +} + +// MARK: - Token Interceptor provider +public final class BuddiesTokenProviderInterceptor: Interceptor { + + enum TokenProviderError: Error, LocalizedError { + case tokenNotFound + + var errorDescription: String? { + switch self { + case .tokenNotFound: "Token is not found." + } + } + } + + public var id: String = UUID().uuidString + + var currentToken: () -> String? + + public init(currentToken: @escaping () -> String?) { + self.currentToken = currentToken + } + + public func intercept( + chain: RequestChain, + operation: HTTPOperation, + response: HTTPResponse?, + completion: @escaping HTTPResultHandler + ) where Request: Requestable { + if let token = currentToken() { + operation.addHeader(key: "Authorization", val: "\(token)") + } + + chain.proceed( + operation: operation, + interceptor: self, + response: response, + completion: completion + ) + } +} diff --git a/SwiftBuddiesIOS/Targets/NetworkModule/Sources/InterceptorProvider/BuddiesJSONDecodingInterceptor.swift b/SwiftBuddiesIOS/Targets/NetworkModule/Sources/InterceptorProvider/BuddiesJSONDecodingInterceptor.swift new file mode 100644 index 0000000..b011b58 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/NetworkModule/Sources/InterceptorProvider/BuddiesJSONDecodingInterceptor.swift @@ -0,0 +1,98 @@ +import Foundation +import BuddiesNetwork + +public final class BuddiesJSONDecodingInterceptor: Interceptor { + public var id: String = UUID().uuidString + + public func intercept( + chain: RequestChain, + operation: HTTPOperation, + response: HTTPResponse?, + completion: @escaping HTTPResultHandler + ) where Request: Requestable { + guard let response = response else { + chain.proceed( + operation: operation, + interceptor: self, + response: response, + completion: completion + ) + return + } + + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let result = try decoder.decode(Request.Data.self, from: response.rawData) + response.parsedData = result + + chain.proceed( + operation: operation, + interceptor: self, + response: response, + completion: completion + ) + } catch let decodingError as DecodingError { + let detailedError = handleDecodingError(decodingError, data: response.rawData, for: operation.properties.requestName) + completion(.failure(detailedError)) + } catch { + completion(.failure(error)) + } + } + + private func handleDecodingError(_ error: DecodingError, data: Data, for model: any Decodable) -> Error { + let description: String + + switch error { + case .keyNotFound(let key, let context): + description = """ + Key '\(key.stringValue)' not found + Debug: \(context.debugDescription) + Coding Path: \(context.codingPath.map(\.stringValue).joined(separator: " -> ")) + for model: \(model) + """ + + case .valueNotFound(let type, let context): + description = """ + Value of type '\(type)' not found + Debug: \(context.debugDescription) + Coding Path: \(context.codingPath.map(\.stringValue).joined(separator: " -> ")) + for model: \(model) + """ + + case .typeMismatch(let type, let context): + description = """ + Type mismatch for type '\(type)' + Debug: \(context.debugDescription) + Coding Path: \(context.codingPath.map(\.stringValue).joined(separator: " -> ")) + for model: \(model) + """ + + case .dataCorrupted(let context): + description = """ + Data corrupted + Debug: \(context.debugDescription) + Coding Path: \(context.codingPath.map(\.stringValue).joined(separator: " -> ")) + for model: \(model) + """ + + @unknown default: + description = error.localizedDescription + } + + // Print raw JSON for debugging + if let jsonString = String(data: data, encoding: .utf8) { + print("🚨 Decoding Error Details:") + print("Error: \(description)") + print("Raw JSON:") + print(jsonString) + } + + return NSError( + domain: "JSONDecodingInterceptor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: description] + ) + } +} diff --git a/SwiftBuddiesIOS/Targets/NetworkModule/Sources/InterceptorProvider/CacheReadInterceptor.swift b/SwiftBuddiesIOS/Targets/NetworkModule/Sources/InterceptorProvider/CacheReadInterceptor.swift new file mode 100644 index 0000000..9b6252b --- /dev/null +++ b/SwiftBuddiesIOS/Targets/NetworkModule/Sources/InterceptorProvider/CacheReadInterceptor.swift @@ -0,0 +1,117 @@ +// +// CacheReadInterceptor.swift +// Network +// +// Created by dogukaan on 26.12.2024. +// + +import Foundation +import BuddiesNetwork + + +final class CacheReadInterceptor: Interceptor { + + var id: String = "com.swiftbuddies.cache-read-interceptor" + + init(store: any CacheStore) { + self.store = store + } + + var store: any CacheStore + + func fetchFromCache( + for operation: HTTPOperation, + chain: any RequestChain, + completion: @escaping (Result) -> Void + ) where Request: Requestable { + store.read(for: operation, chain: chain, completion: completion) + } + + func intercept( + chain: any RequestChain, + operation: HTTPOperation, + response: HTTPResponse?, + completion: @escaping HTTPResultHandler + ) where Request : Requestable { + + // request == .get else continue with the chain + + switch operation.cachePolicy { + case .fetchIgnoringCacheCompletely, + .fetchIgnoringCacheData: + chain.proceed( + operation: operation, + interceptor: self, + response: response, + completion: completion + ) + + case .returnCacheDataAndFetch: + self.fetchFromCache(for: operation, chain: chain) { cacheFetchResult in + switch cacheFetchResult { + case .failure: + // Don't return a cache miss error, just keep going + break + case .success(let decodedData): + let result = HTTPResult(source: .cache, data: decodedData) + chain.returnValue( + for: operation, + result: result, + completion: completion + ) + } + + // In either case, keep going asynchronously + chain.proceed( + operation: operation, + interceptor: self, + response: response, + completion: completion + ) + } + case .returnCacheDataElseFetch: + self.fetchFromCache(for: operation, chain: chain) { cacheFetchResult in + switch cacheFetchResult { + case .failure: + // Cache miss, proceed to network without returning error + chain.proceed( + operation: operation, + interceptor: self, + response: response, + completion: completion + ) + + case .success(let decodedData): + // Cache hit! We're done. + let result = HTTPResult(source: .cache, data: decodedData) + chain.returnValue( + for: operation, + result: result, + completion: completion + ) + } + } + case .returnCacheDataDontFetch: + self.fetchFromCache(for: operation, chain: chain) { cacheFetchResult in + switch cacheFetchResult { + case .failure(let error): + // Cache miss - don't hit the network, just return the error. + chain.handleErrorAsync( + error, + operation: operation, + response: response, + completion: completion + ) + + case .success(let decodedData): + let result = HTTPResult(source: .cache, data: decodedData) + chain.returnValue( + for: operation, + result: result, + completion: completion + ) + } + } + } + } +} diff --git a/SwiftBuddiesIOS/Targets/NetworkModule/Sources/InterceptorProvider/CacheWriteInterceptor.swift b/SwiftBuddiesIOS/Targets/NetworkModule/Sources/InterceptorProvider/CacheWriteInterceptor.swift new file mode 100644 index 0000000..2f8c383 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/NetworkModule/Sources/InterceptorProvider/CacheWriteInterceptor.swift @@ -0,0 +1,130 @@ +// +// CacheWriteInterceptor.swift +// Network +// +// Created by dogukaan on 26.12.2024. +// + +import Foundation +import BuddiesNetwork + +public protocol CacheStore { + func write( + for operation: HTTPOperation, + response: HTTPResponse + ) + func read( + for operation: HTTPOperation, + chain: any RequestChain, + completion: @escaping ( + Result< + Request.Data, + any Error + > + ) -> Void + ) +} + +public class URLCacheStore: CacheStore { + enum CacheStoreError: String, LocalizedError { + case noResponseToParse + + var errorDescription: String? { rawValue } + } + + + private var cache: URLCache + private var jsonDecoder: JSONDecoder = JSONDecoder() + + public init(cache: URLCache = .shared) { + self.cache = cache + } + + public func write(for operation: HTTPOperation, response: HTTPResponse) where Request : Requestable { + let cachedURLResponse = CachedURLResponse(response: response.httpResponse, data: response.rawData) + + do { + let urlRequest = try URLProvider.urlRequest(from: operation.properties) + cache.storeCachedResponse(cachedURLResponse, for: urlRequest) + print("Cache stored for \(operation.properties.requestName)") + } catch { + print("Error while storing cache: \(error)") + return + } + } + + public func read(for operation: HTTPOperation, chain: any RequestChain, completion: @escaping (Result) -> Void) where Request : Requestable { + + do { + let urlRequest = try URLProvider.urlRequest(from: operation.properties) + if let data = cache.cachedResponse(for: urlRequest) { + let decodedData = try jsonDecoder.decode(Request.Data.self, from: data.data) + completion(.success(decodedData)) + } else { + completion(.failure(CacheStoreError.noResponseToParse)) + return + } + print("Cache read for \(operation.properties.requestName)") + } catch { + print("Error while reading cache: \(error)") + } + } +} + +final class CacheWriteInterceptor: Interceptor { + enum CacheWriteError: String, LocalizedError { + case noResponseToParse + + var errorDescription: String? { rawValue } + } + + init(store: any CacheStore) { + self.store = store + } + + var id: String = "com.swiftbuddies.network.cachewriteinterceptor" + var store: any CacheStore + + func intercept( + chain: any RequestChain, + operation: HTTPOperation, + response: HTTPResponse?, + completion: @escaping HTTPResultHandler + ) where Request : Requestable { + guard !chain.isCancelled else { + return + } + + guard operation.cachePolicy != .fetchIgnoringCacheCompletely else { + // If we're ignoring the cache completely, we're not writing to it. + chain.proceed( + operation: operation, + interceptor: self, + response: response, + completion: completion + ) + return + } + + guard let createdResponse = response else { + chain.handleErrorAsync( + CacheWriteError.noResponseToParse, + operation: operation, + response: response, + completion: completion + ) + return + } + + self.store.write(for: operation, response: createdResponse) + + chain.proceed( + operation: operation, + interceptor: self, + response: createdResponse, + completion: completion + ) + } + + +} diff --git a/SwiftBuddiesIOS/Targets/NetworkModule/Sources/InterceptorProvider/GitHubInterceptorProvider.swift b/SwiftBuddiesIOS/Targets/NetworkModule/Sources/InterceptorProvider/GitHubInterceptorProvider.swift new file mode 100644 index 0000000..7d9d25a --- /dev/null +++ b/SwiftBuddiesIOS/Targets/NetworkModule/Sources/InterceptorProvider/GitHubInterceptorProvider.swift @@ -0,0 +1,99 @@ +import Foundation +import BuddiesNetwork + +public final class GitHubInterceptorProvider: InterceptorProvider { + + let client: URLSessionClient + let cacheStore: URLCacheStore + public init(client: URLSessionClient, cacheStore: URLCacheStore = .init()) { + self.cacheStore = cacheStore + self.client = client + } + + public func interceptors(for operation: HTTPOperation) -> [Interceptor] { + [ + MaxRetryInterceptor(maxRetry: 3), + CacheReadInterceptor(store: cacheStore), + GitHubHeadersInterceptor(), + NetworkFetchInterceptor(client: client), + BuddiesJSONDecodingInterceptor(), + CacheWriteInterceptor(store: cacheStore) + ] + } + + public func additionalErrorHandler(for request: some Requestable) -> (any ChainErrorHandler)? { + GitHubErrorHandler() + } +} + +final class GitHubHeadersInterceptor: Interceptor { + public var id: String = UUID().uuidString + + public func intercept( + chain: RequestChain, + operation: HTTPOperation, + response: HTTPResponse?, + completion: @escaping HTTPResultHandler + ) where Request: Requestable { + // Add GitHub API specific headers + operation.addHeader(key: "Accept", val: "application/vnd.github.v3+json") + + chain.proceed( + operation: operation, + interceptor: self, + response: response, + completion: completion + ) + } +} + +final class GitHubErrorHandler: ChainErrorHandler { + func handleError( + error: any Error, + chain: any RequestChain, + operation: HTTPOperation, + response: HTTPResponse?, + completion: @escaping HTTPResultHandler + ) where Request: Requestable { + if response?.httpResponse.statusCode == 403 { + // Handle rate limiting + completion(.failure(GitHubAPIError.rateLimitExceeded)) + } else if response?.httpResponse.statusCode == 404 { + completion(.failure(GitHubAPIError.repositoryNotFound)) + } else if response?.httpResponse.statusCode == 304 { + completion(.failure(GitHubAPIError.notModified)) + } else { + completion(.failure(error)) + } + } +} + +// GitHubAPIError +public enum GitHubAPIError: Error, LocalizedError { + case invalidURL + case networkError(Error) + case decodingError(Error) + case invalidResponse + case rateLimitExceeded + case repositoryNotFound + case notModified + + public var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid URL" + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .decodingError(let error): + return "Failed to decode response: \(error.localizedDescription)" + case .invalidResponse: + return "Invalid response from server" + case .rateLimitExceeded: + return "GitHub API rate limit exceeded. Please try again later." + case .repositoryNotFound: + return "Repository not found" + case .notModified: + return "Not modified" + } + } +} diff --git a/SwiftBuddiesIOS/Targets/Onboardingmodule/Sources/Presentation/OnbordingScreen/Models/OnboardingItemModel.swift b/SwiftBuddiesIOS/Targets/Onboardingmodule/Sources/Presentation/OnbordingScreen/Models/OnboardingItemModel.swift new file mode 100644 index 0000000..b4971c9 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/Onboardingmodule/Sources/Presentation/OnbordingScreen/Models/OnboardingItemModel.swift @@ -0,0 +1,32 @@ +// +// OnboardingItemModel.swift +// Onboarding +// +// Created by Halit Baskurt on 16.04.2024. +// + +import SwiftUI + + +public struct OnboardingItemModel: Identifiable, Hashable { + + public init(id: Int, + title: String, + description: String, + image: Image) { + self.id = id + self.title = title + self.description = description + self.image = image + } + + + public var id: Int + public var title: String + public var description: String + public var image: Image + + public func hash(into hasher: inout Hasher) { + hasher.combine(id.hashValue) + } +} diff --git a/SwiftBuddiesIOS/Targets/Onboardingmodule/Sources/Presentation/OnbordingScreen/OnboardingBuilder.swift b/SwiftBuddiesIOS/Targets/Onboardingmodule/Sources/Presentation/OnbordingScreen/OnboardingBuilder.swift new file mode 100644 index 0000000..6c715b4 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/Onboardingmodule/Sources/Presentation/OnbordingScreen/OnboardingBuilder.swift @@ -0,0 +1,27 @@ +// +// OnboardingBuilder.swift +// Onboarding +// +// Created by Halit Baskurt on 16.04.2024. +// + +import Design +import Localization + +public struct OnboardingBuilder { + public static func build() -> OnboardingView { + + let onboardingItems: [OnboardingItemModel] = [ + .init(id: 0, + title: L.$onboardingitem_firsttitle.localized, + description: L.$onboardingitem_firstdescription.localized, + image: DesignAsset.onboardingWelcomeImage.swiftUIImage), + .init(id: 1, + title: L.$onboardingitem_secondtitle.localized, + description: L.$onboardingitem_seconddescription.localized, + image: DesignAsset.onboardingBuddiesImage.swiftUIImage) + ] + + return OnboardingView(items: onboardingItems) + } +} diff --git a/SwiftBuddiesIOS/Targets/Onboardingmodule/Sources/Presentation/OnbordingScreen/OnboardingCell.swift b/SwiftBuddiesIOS/Targets/Onboardingmodule/Sources/Presentation/OnbordingScreen/OnboardingCell.swift new file mode 100644 index 0000000..93f2297 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/Onboardingmodule/Sources/Presentation/OnbordingScreen/OnboardingCell.swift @@ -0,0 +1,39 @@ +// +// OnboardingCell.swift +// Onboarding +// +// Created by Halit Baskurt on 16.04.2024. +// + +import SwiftUI +import Design + +struct OnboardingCell: View { + + let model: OnboardingItemModel + + var body: some View { + VStack { + ZStack { + HalfCapsule() + .foregroundStyle(.white) + .rotationEffect(.degrees(180)) + .aspectRatio(contentMode: .fit) + .frame(height: 300) + model.image + .resizable() + .padding(.horizontal) + .aspectRatio(contentMode: .fit) + } + Text(model.title) + .font(.system(size: 30,weight: .bold)) + .multilineTextAlignment(.center) + .padding(.bottom,10) + Text(model.description) + .multilineTextAlignment(.center) + Spacer() + } + .frame(maxWidth: .infinity,maxHeight: .infinity) + .ignoresSafeArea(edges: .bottom) + } +} diff --git a/SwiftBuddiesIOS/Targets/Onboardingmodule/Sources/Presentation/OnbordingScreen/OnboardingView.swift b/SwiftBuddiesIOS/Targets/Onboardingmodule/Sources/Presentation/OnbordingScreen/OnboardingView.swift new file mode 100644 index 0000000..40631a7 --- /dev/null +++ b/SwiftBuddiesIOS/Targets/Onboardingmodule/Sources/Presentation/OnbordingScreen/OnboardingView.swift @@ -0,0 +1,63 @@ +// +// OnboardingView.swift +// Onboarding +// +// Created by Halit Baskurt on 16.04.2024. +// + +import SwiftUI +import Design +import Localization + +public struct OnboardingView: View { + @AppStorage("isSplashScreenViewed") var isOnboardingScreenViewed : Bool = false + + init(items onboardingData:[OnboardingItemModel]) { + self.onboardingData = onboardingData + } + + @State private var currentOnboardingItem: Int = 0 + + private var onboardingData: [OnboardingItemModel] + + + public var body: some View { + ZStack { + DesignAsset.onboardingBackround.swiftUIColor + .ignoresSafeArea() + VStack { + TabView(selection: $currentOnboardingItem) { + ForEach(0.. [String] { + print(FileManager.default.fileExists(atPath: filePath).description) + guard let data = FileManager.default.contents(atPath: filePath) else { + print("Failed to read file at path: \(filePath)") + return [] + } + + var lines: [LocalizedLine] = [] + + do { + let decodedData = try JSONDecoder().decode(Localizable.self, from: data) + + for (key,val) in decodedData.strings { + lines.append(.init(key: key, value: val.localizations?.en?.stringUnit?.value ?? "")) + } + dump(decodedData) + } catch { + print("error decoding: \(error.localizedDescription)") + return [] + } + + let fileContent = makeAllContent( + localizationKeys: lines) + + let fileURL = projectDirectoryURL + .appendingPathComponent("LocalizationModule") + .appendingPathComponent("Sources") + .appendingPathComponent("GeneratedLocalizationStrings.swift") + + do { + let newData: Data = fileContent.data(using: .utf8)! + try newData.write(to: URL(filePath: fileURL.path()), options: [.atomic]) + } catch { + print(error.localizedDescription) + } + + return [] + } + + func makePropertyFor( + content: LocalizedLine, + depth: Int + ) -> String { + """ + \(String(repeating: " ", count: depth))/// \(content.value) + \(String(repeating: " ", count: depth))@LocalizedString(key: "\(content.key)") public static var \(sanitizeName(content.key)): Text + """ + } + + func makeAccessorStruct() -> String { + """ + import SwiftUI + + @propertyWrapper + public struct LocalizedString { + public let key: String + public init(key: String) { self.key = key } + + public var wrappedValue: Text { Text(LocalizedStringKey(self.key), bundle: .module) } + public var projectedValue: LocalizedString { self } + public var localized: String { NSLocalizedString(self.key, bundle: .module, comment: "") } + public func format(_ arguments: CVarArg...) -> String { String(format: localized, arguments: arguments) } + public func callAsFunction(_ arguments: CVarArg...) -> String { String(format: localized, arguments: arguments) } + } + + """ +} + + func makeAllContent( + localizationKeys: [LocalizedLine] + ) -> String { + """ + \(makeAccessorStruct()) + + \(makeAccessorList(localizationKeys)) + + """ + } + + func makeAccessorList( + _ list: [LocalizedLine] + ) -> String { + let content = list + .sorted() + .map { makePropertyFor(content: $0, depth: 1) } + .joined(separator: "\n") + + return """ + // MARK: - Localized strings keys + + public enum L { + \(content) + } + """ + } + + private var keywords: Set = [ + "associatedtype", "class", "deinit", "enum", "extension", "fileprivate", "func", "import", "init", "inout", "internal", "let", "open", "operator", "private", "precedencegroup", "protocol", "public", "rethrows", "static", "struct", "subscript", "typealias", "var", "break", "case", "catch", "continue", "default", "defer", "do", "else", "fallthrough", "for", "guard", "if", "in", "repeat", "return", "throw", "switch", "where", "while", "Any", "as", "await", "catch", "false", "is", "nil", "rethrows", "self", "Self", "super", "throw", "throws", "true", "try", "associativity", "convenience", "didSet", "dynamic", "final", "get", "indirect", "infix", "lazy", "left", "mutating", "none", "nonmutating", "optional", "override", "postfix", "precedence", "prefix", "Protocol", "required", "right", "set", "some", "Type", "unowned", "weak", "willSet", + ] + + func sanitizeName(_ name: S) -> String { + var newName = name + .replacingOccurrences(of: "-", with: "_") + .replacingOccurrences(of: ".", with: "_") + + if newName.starts(with: #/\d/#) { + newName = "_\(newName)" + } + + if keywords.contains(newName) { + newName = "`\(newName)`" + } + + return newName.lowercased() + } +} + +struct LocalizedLine: Comparable { + static func < (lhs: LocalizedLine, rhs: LocalizedLine) -> Bool { + lhs.key < rhs.value + } + + let key: String + let value: String +} + +struct Localizable: Codable { + let strings: [String: StringValue] +} + +// MARK: - StringValue +struct StringValue: Codable { + let localizations: Localizations? +} + +// MARK: - Localizations +struct Localizations: Codable { + let en: En? +} + +// MARK: - En +struct En: Codable { + let stringUnit: StringUnit? +} + +// MARK: - StringUnit +struct StringUnit: Codable { + let value: String? +} diff --git a/SwiftBuddiesIOS/Tests/SwiftBuddiesIOSTests.swift b/SwiftBuddiesIOS/Tests/SwiftBuddiesIOSTests.swift new file mode 100644 index 0000000..6b7e4d3 --- /dev/null +++ b/SwiftBuddiesIOS/Tests/SwiftBuddiesIOSTests.swift @@ -0,0 +1,8 @@ +import Foundation +import XCTest + +final class SwiftBuddiesIOSTests: XCTestCase { + func test_twoPlusTwo_isFour() { + XCTAssertEqual(2+2, 4) + } +} \ No newline at end of file diff --git a/Targets/SwiftBuddiesContributors/Sources/ContributorsView.swift b/Targets/SwiftBuddiesContributors/Sources/ContributorsView.swift deleted file mode 100644 index 54824ba..0000000 --- a/Targets/SwiftBuddiesContributors/Sources/ContributorsView.swift +++ /dev/null @@ -1,18 +0,0 @@ -import SwiftUI -import Design - -public struct ContributorsView: View { - - public init() { } - - public var body: some View { - VStack { - Text("Contributors Module") - Text(ViewEnum.hello.rawValue) - } - } -} - -#Preview { - ContributorsView() -} diff --git a/Targets/SwiftBuddiesDesign/Resources/AppIcon/Assets.xcassets/AppIcon.appiconset/1024.png b/Targets/SwiftBuddiesDesign/Resources/AppIcon/Assets.xcassets/AppIcon.appiconset/1024.png deleted file mode 100644 index de22cf6..0000000 Binary files a/Targets/SwiftBuddiesDesign/Resources/AppIcon/Assets.xcassets/AppIcon.appiconset/1024.png and /dev/null differ diff --git a/Targets/SwiftBuddiesDesign/Resources/AppIcon/Assets.xcassets/AppIcon.appiconset/Contents.json b/Targets/SwiftBuddiesDesign/Resources/AppIcon/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index cff1680..0000000 --- a/Targets/SwiftBuddiesDesign/Resources/AppIcon/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "1024.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Targets/SwiftBuddiesFeed/Sources/FeedView.swift b/Targets/SwiftBuddiesFeed/Sources/FeedView.swift deleted file mode 100644 index b3a85fa..0000000 --- a/Targets/SwiftBuddiesFeed/Sources/FeedView.swift +++ /dev/null @@ -1,19 +0,0 @@ - -import SwiftUI -import Design - -public struct FeedView: View { - - public init() { } - - public var body: some View { - VStack { - Text("Feed Module") - Text(ViewEnum.hello.rawValue) - } - } -} - -#Preview { - FeedView() -} diff --git a/Targets/SwiftBuddiesMain/Sources/AppMain.swift b/Targets/SwiftBuddiesMain/Sources/AppMain.swift deleted file mode 100644 index 23adb4a..0000000 --- a/Targets/SwiftBuddiesMain/Sources/AppMain.swift +++ /dev/null @@ -1,54 +0,0 @@ -import SwiftUI -import Feed -import Map -import About -import Contributors - -enum AppTab: Int, Identifiable { - case feed = 0 - case map - case about - case contributors - - var id: Int { rawValue } -} - - -@main -struct AppMain: App { - - @State var selectedTab: AppTab = .feed - - var body: some Scene { - WindowGroup { - TabView(selection: $selectedTab) { - FeedView() - .tabItem { - Image(systemName: "list.bullet") - Text("Feed") - } - - .tag(AppTab.feed) - MapView() - .tabItem { - Image(systemName: "map") - Text("Map") - } - .tag(AppTab.map) - AboutView() - .tabItem { - Image(systemName: "info.circle") - Text("About") - } - .tag(AppTab.about) - - ContributorsView() - .tabItem { - Image(systemName: "person.3") - Text("Contributors") - } - .tag(AppTab.contributors) - } - } - } -} diff --git a/Targets/SwiftBuddiesMap/Sources/MapView.swift b/Targets/SwiftBuddiesMap/Sources/MapView.swift deleted file mode 100644 index 8d71721..0000000 --- a/Targets/SwiftBuddiesMap/Sources/MapView.swift +++ /dev/null @@ -1,162 +0,0 @@ -import SwiftUI -import MapKit -import Design - -public struct MapView: View { -// @State private var selectedCategory: String? = nil - @State private var locations: [Location] = [ - Location(name: "Boga Heykeli", coordinate: CLLocationCoordinate2D(latitude: 40.990467, longitude: 29.029162)), - Location(name: "Coffee Shop 1", coordinate: CLLocationCoordinate2D(latitude: 41.043544, longitude: 29.004255)), - Location(name: "Coffee Shop 1", coordinate: CLLocationCoordinate2D(latitude: 41.06, longitude: 29)), - ] - - @State private var categoryModalShown = false - @State private var selectedCategory: String = "Select Location" - @State private var selectedDetent: PresentationDetent = .fraction(0.2) - @State private var dismissableMessage: Bool = false - - public init() {} - - public var body: some View { - ZStack { - MapLocationsView(locations: locations) - .edgesIgnoringSafeArea([.top, .leading, .trailing]) - .bottomSheet( - presentationDetents: [.large, .fraction(0.2), .fraction(0.4), .fraction(0.5), .medium], - detentSelection: $selectedDetent, - isPresented: $categoryModalShown, - sheetCornerRadius: 12, - interactiveDismissDisabled: false) { - CategoryPicker(selectedCategory: $selectedCategory) { - selectedDetent = .fraction(0.2) - dismissableMessage.toggle() - } - } onDismiss: { - - } - if !categoryModalShown { - VStack { - Spacer() - Button(action: { - categoryModalShown.toggle() - }) { - Text("See Locations") - .foregroundColor(.white) - .padding() - .background(Color.blue) - .cornerRadius(10) - } - .padding() - } - } - - DismissableMessage(displayMessage: $dismissableMessage, delay: 3.0) { - Text("Selected: \(selectedCategory)") - .padding() - .foregroundColor(.white) - .background(Color.black.opacity(0.75)) - .cornerRadius(5) - .padding(.top, 80) // Adjust this to properly place on the screen - } - } - } -} - -#Preview { - MapView() -} - -// Define a simple model for location -struct Location: Identifiable { - let id = UUID() - let name: String - let coordinate: CLLocationCoordinate2D - let image: Image? = nil - let backgroundColor: Color? = nil -} - -// Map view -struct MapLocationsView: View { - var locations: [Location] - - var body: some View { - VStack { - Map(position: .constant(.automatic)) { - ForEach(locations) { location in - Annotation(coordinate: location.coordinate) { - ZStack { - RoundedRectangle(cornerRadius: 5) - .fill(location.backgroundColor ?? Color.teal) - if let image = location.image { - image - .frame(width: 12, height: 12) - .padding(5) - } else { - Image(systemName: "house") - .frame(width: 24, height: 24) - .padding(5) - } - } - } label: { - Text(location.name) - } - - } -// Annotation("Columbia University", coordinate: .columbiaUniversity) { -// ZStack { -// RoundedRectangle(cornerRadius: 5) -// .fill(Color.teal) -// Text("🎓") -// .padding(5) -// } -// } - } - -// Map( -// coordinateRegion: .constant( -// MKCoordinateRegion( -// center: CLLocationCoordinate2D(latitude: 41.04, longitude: 29), -// latitudinalMeters: 10000, -// longitudinalMeters: 10000 -// ) -// ), -// annotationItems: locations -// ) { location in -// MapPin(coordinate: location.coordinate, tint: Color.orange) -// } - } - } -} - -struct CategoryPicker: View { - @Environment(\.presentationMode) var presentationMode - - @Binding var selectedCategory: String - let categories = ["Coffee Shops", "Where to Work", "Meeting Points"] - var selectAction: () -> Void - - var body: some View { - NavigationView { - List { - ForEach(categories, id: \.self) { category in - Button(action: { - selectedCategory = category - selectAction() - }) { - Text(category) - .padding() - .frame(maxWidth: .infinity) - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(10) - .padding(.horizontal) - } - } - } - .navigationTitle("Select Category") - .navigationBarItems(trailing: Button("Dismiss") { - presentationMode.wrappedValue.dismiss() - }) - } - } -} diff --git a/Targets/SwiftBuddiesNetwork/Sources/Network.swift b/Targets/SwiftBuddiesNetwork/Sources/Network.swift deleted file mode 100644 index e69de29..0000000 diff --git a/Tuist/Config.swift b/Tuist/Config.swift deleted file mode 100644 index 8c163e9..0000000 --- a/Tuist/Config.swift +++ /dev/null @@ -1,7 +0,0 @@ -import ProjectDescription - -let config = Config( - plugins: [ - .local(path: .relativeToManifest("../../Plugins/SwiftBuddiesIOS")), - ] -) diff --git a/Tuist/Dependencies.swift b/Tuist/Dependencies.swift deleted file mode 100644 index d82dd63..0000000 --- a/Tuist/Dependencies.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Dependencies.swift -// Config -// -// Created by dogukaan on 20.01.2024. -// - -import Foundation -import ProjectDescription - -let dependencies = Dependencies( - swiftPackageManager: .init( - [.remote(url: "https://github.com/darkbringer1/DefaultNetworkOperationPackage", - requirement: .upToNextMajor(from: .init(1, 0, 0))), - .remote(url: "https://github.com/SwiftUIX/SwiftUIX.git", - requirement: .upToNextMinor(from: .init(0, 1, 0)))] - ), - platforms: [.iOS, .macOS] -) diff --git a/Tuist/Package.swift b/Tuist/Package.swift new file mode 100644 index 0000000..4a3595c --- /dev/null +++ b/Tuist/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 5.9 +import PackageDescription + +#if TUIST + import ProjectDescription + + let packageSettings = PackageSettings( + // Customize the product types for specific package product + // Default is .staticFramework + // productTypes: ["Alamofire": .framework,] + productTypes: [:] + ) +#endif + +let package = Package( + name: "SwiftBuddiesIOS", + dependencies: [ + // Add your own dependencies here: + // .package(url: "https://github.com/Alamofire/Alamofire", from: "5.0.0"), + // You can read more about dependencies here: https://docs.tuist.io/documentation/tuist/dependencies + ] +) diff --git a/Tuist/ProjectDescriptionHelpers/Project+Templates.swift b/Tuist/ProjectDescriptionHelpers/Project+Templates.swift deleted file mode 100644 index 522b57c..0000000 --- a/Tuist/ProjectDescriptionHelpers/Project+Templates.swift +++ /dev/null @@ -1,63 +0,0 @@ -import ProjectDescription - -/// Project helpers are functions that simplify the way you define your project. -/// Share code to create targets, settings, dependencies, -/// Create your own conventions, e.g: a func that makes sure all shared targets are "static frameworks" -/// See https://docs.tuist.io/guides/helpers/ - -extension Project { - /// Helper function to create the Project for this ExampleApp - public static func app( - name: String, - platform: Platform, - additionalTargets: [Target], - targetDependencies: [TargetDependency]? = nil - ) -> Project { - var targetDependencies = targetDependencies ?? [] - targetDependencies.append(contentsOf: additionalTargets.compactMap({ TargetDependency.target(name: $0.name) })) - var targets = makeAppTargets(name: name, - platform: platform, - dependencies: targetDependencies) - - - targets += additionalTargets - return Project(name: name, - organizationName: "SwiftBuddies", - targets: targets) - } - - // MARK: - Private - - /// Helper function to create the application target and the unit test target. - private static func makeAppTargets(name: String, platform: Platform, dependencies: [TargetDependency]) -> [Target] { - let platform: Platform = platform - let infoPlist: [String: Plist.Value] = [ - "CFBundleShortVersionString": "1.0", - "CFBundleVersion": "1", - "UIMainStoryboardFile": "", - "UILaunchStoryboardName": "LaunchScreen" - ] - let mainTarget = Target( - name: name, - platform: platform, - product: .app, - bundleId: "com.swiftbuddies.\(name.lowercased())", - infoPlist: .extendingDefault(with: infoPlist), - sources: ["Targets/\(name)/Sources/**"], -// resources: ["Targets/\(name)/Resources/**"], - dependencies: dependencies - ) - - let testTarget = Target( - name: "\(name)Tests", - platform: platform, - product: .unitTests, - bundleId: "com.swiftbuddies.\(name.lowercased())Tests", - infoPlist: .default, - sources: ["Targets/\(name)/Tests/**"], - dependencies: [ - .target(name: "\(name)") - ]) - return [mainTarget] - } -} diff --git a/graph.png b/graph.png deleted file mode 100644 index 2144f6a..0000000 Binary files a/graph.png and /dev/null differ