diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..3402c2d Binary files /dev/null and b/.DS_Store differ diff --git a/DEVELOPER_MANUAL.md b/DEVELOPER_MANUAL.md new file mode 100644 index 0000000..e8ceb90 --- /dev/null +++ b/DEVELOPER_MANUAL.md @@ -0,0 +1,223 @@ +# MeteorShowers App Developer Manual + +## Table of Contents +1. [Architecture Overview](#architecture-overview) +2. [Core Components](#core-components) +3. [Dependencies](#dependencies) +4. [File Structure](#file-structure) +5. [Key Features](#key-features) +6. [Notifications System](#notifications-system) +7. [Location Services](#location-services) +8. [UI Components](#ui-components) + +## Architecture Overview + +MeteorShowers is an iOS application built using UIKit that helps users track meteor showers and provides relevant astronomical data. The app follows a traditional MVC (Model-View-Controller) architecture pattern. + +### Design Patterns Used +- Singleton (LocationManager, RemindersManager) +- Delegate Pattern (LocationManagerDelegate) +- Observer Pattern (NotificationCenter for updates) + +## Core Components + +### Models +1. **MeteorShower** + - Properties: name, datePeak, ZHR (Zenithal Hourly Rate) + - Used to represent individual meteor shower events + +2. **LocationManager** + - Singleton class managing device location + - Handles location permissions and updates + - Provides location data to TopView for calculations + +3. **RemindersManager** + - Manages local notifications for meteor shower reminders + - Handles persistence of reminder settings + - Methods: + - `saveReminder(for:)` + - `removeReminder(for:)` + - `hasReminder(for:)` + - `restoreAllReminders()` + +4. **AstronomicalCalculations** + - Contains moon phase calculation logic + - Enum `MoonPhase` for different moon phases + - Struct `MoonPhaseCalculation` for calculations + +### ViewControllers + +1. **MainViewController** + - Root view controller + - Displays list of meteor showers + - Background color: .darkMidnight + +2. **ShowerDetailViewController** + - Shows detailed information about selected shower + - Manages reminder functionality + - Key UI elements: + - Reminder bell icon + - Peak date + - ZHR information + - Moon phase details + +### Views + +1. **TopView** + - Custom view showing astronomical data + - Dependencies: + - LocationManager + - MoonPhaseCalculation + - SunRiseSetCalc + - Features: + - Sunrise/sunset times + - Moon phase + - Location-based calculations + +2. **MeteorShowerTableViewCell** + - Custom cell for meteor shower list + - Background: .lightMidnight + - Corner radius: 9pt + +## Dependencies + +### External Libraries +1. **Alamofire** + - Used for network requests + - Installation: Swift Package Manager + +2. **SnapKit** + - Auto-layout DSL + - Used for programmatic constraints + +### Frameworks +1. **UIKit** + - Main UI framework +2. **CoreLocation** + - Location services +3. **UserNotifications** + - Local notifications for reminders + +## File Structure + +``` +MeteorShowers/ +├── App/ +│ ├── SceneDelegate.swift +│ ├── AppDelegate.swift +│ └── Info.plist +├── ViewControllers/ +│ ├── MainViewController.swift +│ └── ShowerDetailViewController.swift +├── Views/ +│ ├── TopView.swift +│ └── MeteorShowerTableViewCell.swift +├── Models/ +│ ├── MeteorShower.swift +│ ├── LocationManager.swift +│ └── AstronomicalCalculations.swift +├── Managers/ +│ └── RemindersManager.swift +└── Assets.xcassets/ +``` + +## Key Features + +### Reminder System +- Set reminders 3 days before meteor shower peaks +- Persistent storage using UserDefaults +- Local notifications using UNUserNotificationCenter +- Visual feedback with bell icon + +### Location Services +- Uses CoreLocation for user position +- Permission handling: "When In Use" authorization +- Fallback to Moscow coordinates if location unavailable +- Used for calculating: + - Sunrise/sunset times + - Moon phase visibility + +### Astronomical Calculations +1. **Sun Calculations** + - Sunrise/sunset times based on location + - Uses UTC time zone to avoid conversion issues + +2. **Moon Phase** + - Calculates current moon phase + - Shows illumination percentage + - Updates based on current date + +## UI Components + +### Color Scheme +- `.darkMidnight`: Main background color +- `.lightMidnight`: Secondary background color +- `.lightMidnight1`: Navigation title color + +### Typography +- Navigation title: System font, large title +- Labels: System font, 12pt, weight: thin +- Time labels: System font, 12pt, weight: thin + +### Layout +- Corner radius: 12pt for main views +- Standard padding: 16pt +- Icon sizes: 24x24pt + +## Notifications System + +### Local Notifications +- Scheduled 3 days before peak +- Unique identifier format: "meteorShower-[name]" +- Permission requested on first reminder set +- Persisted across app restarts + +### Permission Handling +```swift +NSUserNotificationUsageDescription: "We'll notify you 3 days before meteor shower peaks so you don't miss the show!" +``` + +## Location Services + +### Permission Handling +```swift +NSLocationWhenInUseUsageDescription: "We need your location to calculate accurate sunrise and sunset times for your area." +``` + +### Location Updates +- Accuracy level: Best +- Updates when: + 1. App launches + 2. Returns from background + 3. Location permission granted + +## Best Practices + +1. **Memory Management** + - Use weak self in closures + - Proper deinitialization of observers + - Cleanup of notification requests + +2. **Error Handling** + - Location fallback mechanism + - Graceful degradation of features + - User feedback for failures + +3. **Performance** + - Reusable cells for list + - Efficient astronomical calculations + - Proper threading for UI updates + +4. **Code Organization** + - Clear separation of concerns + - Protocol-oriented design + - Extension-based organization + +## Contributing + +When contributing to this project: +1. Follow the existing architecture patterns +2. Maintain consistent code style +3. Add appropriate documentation +4. Test on multiple iOS versions +5. Consider backward compatibility diff --git a/MeteorShowers.xcodeproj/project.pbxproj b/MeteorShowers.xcodeproj/project.pbxproj new file mode 100644 index 0000000..5a65b10 --- /dev/null +++ b/MeteorShowers.xcodeproj/project.pbxproj @@ -0,0 +1,412 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 57BDB0152D4B641B0013AC04 /* OpenMeteoSdk in Frameworks */ = {isa = PBXBuildFile; productRef = 57BDB0142D4B641B0013AC04 /* OpenMeteoSdk */; }; + 57BDB0182D4BA6FD0013AC04 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 57BDB0172D4BA6FD0013AC04 /* Alamofire */; }; + 57E994872D4652F900912D66 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 57E994862D4652F900912D66 /* SnapKit */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 57E9946B2D46517500912D66 /* MeteorShowers.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MeteorShowers.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 57E9947D2D46517600912D66 /* Exceptions for "MeteorShowers" folder in "MeteorShowers" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + App/Info.plist, + ); + target = 57E9946A2D46517500912D66 /* MeteorShowers */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 57E9946D2D46517500912D66 /* MeteorShowers */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 57E9947D2D46517600912D66 /* Exceptions for "MeteorShowers" folder in "MeteorShowers" target */, + ); + path = MeteorShowers; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 57E994682D46517500912D66 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 57BDB0182D4BA6FD0013AC04 /* Alamofire in Frameworks */, + 57BDB0152D4B641B0013AC04 /* OpenMeteoSdk in Frameworks */, + 57E994872D4652F900912D66 /* SnapKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 57E994622D46517500912D66 = { + isa = PBXGroup; + children = ( + 57E9946D2D46517500912D66 /* MeteorShowers */, + 57E9946C2D46517500912D66 /* Products */, + ); + sourceTree = ""; + }; + 57E9946C2D46517500912D66 /* Products */ = { + isa = PBXGroup; + children = ( + 57E9946B2D46517500912D66 /* MeteorShowers.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 57E9946A2D46517500912D66 /* MeteorShowers */ = { + isa = PBXNativeTarget; + buildConfigurationList = 57E9947E2D46517600912D66 /* Build configuration list for PBXNativeTarget "MeteorShowers" */; + buildPhases = ( + 57E994672D46517500912D66 /* Sources */, + 57E994682D46517500912D66 /* Frameworks */, + 57E994692D46517500912D66 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 57E9946D2D46517500912D66 /* MeteorShowers */, + ); + name = MeteorShowers; + packageProductDependencies = ( + 57E994862D4652F900912D66 /* SnapKit */, + 57BDB0142D4B641B0013AC04 /* OpenMeteoSdk */, + 57BDB0172D4BA6FD0013AC04 /* Alamofire */, + ); + productName = MeteorShowers; + productReference = 57E9946B2D46517500912D66 /* MeteorShowers.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 57E994632D46517500912D66 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1610; + LastUpgradeCheck = 1610; + TargetAttributes = { + 57E9946A2D46517500912D66 = { + CreatedOnToolsVersion = 16.1; + }; + }; + }; + buildConfigurationList = 57E994662D46517500912D66 /* Build configuration list for PBXProject "MeteorShowers" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 57E994622D46517500912D66; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 57E994852D4652F900912D66 /* XCRemoteSwiftPackageReference "SnapKit" */, + 57BDB0132D4B641B0013AC04 /* XCRemoteSwiftPackageReference "sdk" */, + 57BDB0162D4BA6FD0013AC04 /* XCRemoteSwiftPackageReference "Alamofire" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 57E9946C2D46517500912D66 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 57E9946A2D46517500912D66 /* MeteorShowers */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 57E994692D46517500912D66 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 57E994672D46517500912D66 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 57E9947F2D46517600912D66 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = MF7QC2JX3N; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MeteorShowers/App/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Meteor Showers"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ru.swiftvibes.MeteorShowers; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 57E994802D46517600912D66 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = MF7QC2JX3N; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MeteorShowers/App/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Meteor Showers"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ru.swiftvibes.MeteorShowers; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 57E994812D46517600912D66 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.1; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 57E994822D46517600912D66 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.1; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 57E994662D46517500912D66 /* Build configuration list for PBXProject "MeteorShowers" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 57E994812D46517600912D66 /* Debug */, + 57E994822D46517600912D66 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 57E9947E2D46517600912D66 /* Build configuration list for PBXNativeTarget "MeteorShowers" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 57E9947F2D46517600912D66 /* Debug */, + 57E994802D46517600912D66 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 57BDB0132D4B641B0013AC04 /* XCRemoteSwiftPackageReference "sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/open-meteo/sdk.git"; + requirement = { + branch = main; + kind = branch; + }; + }; + 57BDB0162D4BA6FD0013AC04 /* XCRemoteSwiftPackageReference "Alamofire" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Alamofire/Alamofire.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.10.2; + }; + }; + 57E994852D4652F900912D66 /* XCRemoteSwiftPackageReference "SnapKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SnapKit/SnapKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.7.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 57BDB0142D4B641B0013AC04 /* OpenMeteoSdk */ = { + isa = XCSwiftPackageProductDependency; + package = 57BDB0132D4B641B0013AC04 /* XCRemoteSwiftPackageReference "sdk" */; + productName = OpenMeteoSdk; + }; + 57BDB0172D4BA6FD0013AC04 /* Alamofire */ = { + isa = XCSwiftPackageProductDependency; + package = 57BDB0162D4BA6FD0013AC04 /* XCRemoteSwiftPackageReference "Alamofire" */; + productName = Alamofire; + }; + 57E994862D4652F900912D66 /* SnapKit */ = { + isa = XCSwiftPackageProductDependency; + package = 57E994852D4652F900912D66 /* XCRemoteSwiftPackageReference "SnapKit" */; + productName = SnapKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 57E994632D46517500912D66 /* Project object */; +} diff --git a/MeteorShowers.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/MeteorShowers.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/MeteorShowers.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/MeteorShowers.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/MeteorShowers.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..09a5f89 --- /dev/null +++ b/MeteorShowers.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "48a78d05fda71a2681982bb7ce0483c4c94e8a750482ff401b9989bd548e3ab4", + "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", + "version" : "5.10.2" + } + }, + { + "identity" : "flatbuffers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/flatbuffers.git", + "state" : { + "revision" : "595bf0007ab1929570c7671f091313c8fc20644e", + "version" : "24.3.25" + } + }, + { + "identity" : "sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/open-meteo/sdk.git", + "state" : { + "branch" : "main", + "revision" : "df6da44757b014199275bff3b03df7b5d9876d94" + } + }, + { + "identity" : "snapkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SnapKit/SnapKit.git", + "state" : { + "revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4", + "version" : "5.7.1" + } + } + ], + "version" : 3 +} diff --git a/MeteorShowers.xcodeproj/xcshareddata/xcschemes/MeteorShowers.xcscheme b/MeteorShowers.xcodeproj/xcshareddata/xcschemes/MeteorShowers.xcscheme new file mode 100644 index 0000000..a6d5b3a --- /dev/null +++ b/MeteorShowers.xcodeproj/xcshareddata/xcschemes/MeteorShowers.xcscheme @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MeteorShowers.xcodeproj/xcuserdata/victor.xcuserdatad/xcschemes/xcschememanagement.plist b/MeteorShowers.xcodeproj/xcuserdata/victor.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..a45fa74 --- /dev/null +++ b/MeteorShowers.xcodeproj/xcuserdata/victor.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,27 @@ + + + + + SchemeUserState + + MeteorShowers.xcscheme_^#shared#^_ + + orderHint + 0 + + SnapKitPlayground (Playground).xcscheme + + orderHint + 1 + + + SuppressBuildableAutocreation + + 57E9946A2D46517500912D66 + + primary + + + + + diff --git a/MeteorShowers/.DS_Store b/MeteorShowers/.DS_Store new file mode 100644 index 0000000..3b2ab5b Binary files /dev/null and b/MeteorShowers/.DS_Store differ diff --git a/MeteorShowers/App/AppDelegate.swift b/MeteorShowers/App/AppDelegate.swift new file mode 100644 index 0000000..08d131c --- /dev/null +++ b/MeteorShowers/App/AppDelegate.swift @@ -0,0 +1,36 @@ +// +// AppDelegate.swift +// MeteorShowers +// +// Created by Vic on 26.01.2025. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + +} + diff --git a/MeteorShowers/App/Info.plist b/MeteorShowers/App/Info.plist new file mode 100644 index 0000000..24ae782 --- /dev/null +++ b/MeteorShowers/App/Info.plist @@ -0,0 +1,27 @@ + + + + + NSLocationWhenInUseUsageDescription + We need your location to calculate accurate sunrise and sunset times for your area. + NSUserNotificationUsageDescription + We'll notify you 3 days before meteor shower peaks so you don't miss the show! + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/MeteorShowers/App/SceneDelegate.swift b/MeteorShowers/App/SceneDelegate.swift new file mode 100644 index 0000000..d1ab28a --- /dev/null +++ b/MeteorShowers/App/SceneDelegate.swift @@ -0,0 +1,77 @@ +// +// SceneDelegate.swift +// MeteorShowers +// +// Created by Vic on 26.01.2025. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + + // MARK: - since we don't use storyboards - this must be implemented + guard let customWindowScene = (scene as? UIWindowScene) else { return } + + // Restore reminders when app launches + RemindersManager.shared.restoreAllReminders() + + // custom window since we don't use Storyboards + window = UIWindow(frame: customWindowScene.coordinateSpace.bounds) + window?.windowScene = customWindowScene + + // root VC + let mainViewController = MainViewController() + + // assigning root vc to nav + let navigationController = UINavigationController(rootViewController: mainViewController) + window?.rootViewController = navigationController + + // configuring nav title + let titleColor: UIColor = .gray + navigationController.navigationBar.largeTitleTextAttributes = [ + .foregroundColor: titleColor + ] + navigationController.navigationBar.titleTextAttributes = [ + .foregroundColor: titleColor + ] + navigationController.navigationBar.prefersLargeTitles = true + + // setting title to nav via mainVC + mainViewController.title = "Meteor Showers" + window?.makeKeyAndVisible() + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } + + +} diff --git a/MeteorShowers/Assets.xcassets/AccentColor.colorset/Contents.json b/MeteorShowers/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/MeteorShowers/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MeteorShowers/Assets.xcassets/AppIcon.appiconset/Contents.json b/MeteorShowers/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ed740ec --- /dev/null +++ b/MeteorShowers/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "icon_main.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MeteorShowers/Assets.xcassets/AppIcon.appiconset/icon_main.png b/MeteorShowers/Assets.xcassets/AppIcon.appiconset/icon_main.png new file mode 100644 index 0000000..0105dfe Binary files /dev/null and b/MeteorShowers/Assets.xcassets/AppIcon.appiconset/icon_main.png differ diff --git a/MeteorShowers/Assets.xcassets/Contents.json b/MeteorShowers/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/MeteorShowers/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MeteorShowers/Assets.xcassets/bg_m.imageset/Contents.json b/MeteorShowers/Assets.xcassets/bg_m.imageset/Contents.json new file mode 100644 index 0000000..25bd8c1 --- /dev/null +++ b/MeteorShowers/Assets.xcassets/bg_m.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bg_m.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MeteorShowers/Assets.xcassets/bg_m.imageset/bg_m.png b/MeteorShowers/Assets.xcassets/bg_m.imageset/bg_m.png new file mode 100644 index 0000000..a677f14 Binary files /dev/null and b/MeteorShowers/Assets.xcassets/bg_m.imageset/bg_m.png differ diff --git a/MeteorShowers/Assets.xcassets/dark_midnight.colorset/Contents.json b/MeteorShowers/Assets.xcassets/dark_midnight.colorset/Contents.json new file mode 100644 index 0000000..cc2a30c --- /dev/null +++ b/MeteorShowers/Assets.xcassets/dark_midnight.colorset/Contents.json @@ -0,0 +1,29 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.325", + "green" : "0.157", + "red" : "0.090" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MeteorShowers/Assets.xcassets/glow_midnight.colorset/Contents.json b/MeteorShowers/Assets.xcassets/glow_midnight.colorset/Contents.json new file mode 100644 index 0000000..54d8eaa --- /dev/null +++ b/MeteorShowers/Assets.xcassets/glow_midnight.colorset/Contents.json @@ -0,0 +1,29 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.651", + "green" : "0.394", + "red" : "0.240" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MeteorShowers/Assets.xcassets/light_midnight.colorset/Contents.json b/MeteorShowers/Assets.xcassets/light_midnight.colorset/Contents.json new file mode 100644 index 0000000..83eb863 --- /dev/null +++ b/MeteorShowers/Assets.xcassets/light_midnight.colorset/Contents.json @@ -0,0 +1,29 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.366", + "green" : "0.195", + "red" : "0.117" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MeteorShowers/Assets.xcassets/light_midnight_1.colorset/Contents.json b/MeteorShowers/Assets.xcassets/light_midnight_1.colorset/Contents.json new file mode 100644 index 0000000..3056148 --- /dev/null +++ b/MeteorShowers/Assets.xcassets/light_midnight_1.colorset/Contents.json @@ -0,0 +1,29 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.540", + "green" : "0.311", + "red" : "0.191" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MeteorShowers/Assets.xcassets/mock.imageset/Contents.json b/MeteorShowers/Assets.xcassets/mock.imageset/Contents.json new file mode 100644 index 0000000..f32e663 --- /dev/null +++ b/MeteorShowers/Assets.xcassets/mock.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "mock_pix.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MeteorShowers/Assets.xcassets/mock.imageset/mock_pix.jpg b/MeteorShowers/Assets.xcassets/mock.imageset/mock_pix.jpg new file mode 100644 index 0000000..b2bbe69 Binary files /dev/null and b/MeteorShowers/Assets.xcassets/mock.imageset/mock_pix.jpg differ diff --git a/MeteorShowers/Base.lproj/LaunchScreen.storyboard b/MeteorShowers/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..92cf06a --- /dev/null +++ b/MeteorShowers/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MeteorShowers/Cells/MeteorShowerTableViewCell.swift b/MeteorShowers/Cells/MeteorShowerTableViewCell.swift new file mode 100644 index 0000000..626ed78 --- /dev/null +++ b/MeteorShowers/Cells/MeteorShowerTableViewCell.swift @@ -0,0 +1,219 @@ +// +// MeteorShowerTableViewCell.swift +// MeteorShowers +// +// Created by Vic on 27.01.2025. +// + +import UIKit +import SnapKit + +final class MeteorShowerTableViewCell: UITableViewCell { + + // MARK: - UI Elements + private lazy var containerView: UIView = { + let view = UIView() + view.backgroundColor = .lightMidnight + view.layer.cornerRadius = 9 + view.clipsToBounds = true + return view + }() + + // MARK: - Stacks + private lazy var mainStackView: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.alignment = .fill + stack.spacing = 8 + stack.distribution = .fillProportionally + + return stack + }() + + // used for info about shower + private lazy var infoStackView: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = 1 + stack.alignment = .fill + stack.distribution = .fillEqually + + return stack + }() + + // dates inside info, inside this stack three labels + private lazy var datesStackView: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 8 + stack.alignment = .fill + stack.distribution = .fillEqually + + return stack + }() + + // used for moon phase, img+zhr label + private lazy var moonAndOriginStackView: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.alignment = .fill + stack.distribution = .fillEqually + + return stack + }() + + private lazy var moonStackView: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.alignment = .leading + stack.spacing = 8 + stack.distribution = .equalCentering + + return stack + }() + + private lazy var originStackView: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.alignment = .leading + stack.distribution = .fillEqually + + return stack + }() + + // MARK: - Labels + // title + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 16, weight: .semibold) + label.textColor = .lightGray + label.text = "Meteor Shower" + return label + }() + + // dates labels + private lazy var datePeakLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12, weight: .heavy) + label.textColor = .lightGray + label.text = "Peak: TBD" + return label + }() + + private lazy var dateBeginLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 11) + label.textColor = .gray + label.text = "Begins: TBD" + return label + }() + + private lazy var dateEndLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12) + label.textColor = .gray + label.text = "Ends: TBD" + return label + }() + + // moon image + private lazy var moonIconImageView: UIImageView = { + let image = UIImageView(image: UIImage(systemName: "moonphase.waxing.gibbous")) + image.tintColor = .lightGray + return image + }() + + // ZHR label + private lazy var ZHRLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 14, weight: .bold) + label.textAlignment = .left + label.textColor = .gray + label.text = "" + return label + }() + + // Shower origin + private lazy var originLabel: UILabel = { + let label = UILabel() + label.textColor = .gray + label.font = .systemFont(ofSize: 11, weight: .heavy) + label.numberOfLines = 2 + label.textColor = .lightGray + label.text = "Comet" + + return label + }() + + // Add moonPhaseCalculator property + private let moonPhaseCalculator = MoonPhaseCalculation() + + // MARK: - Initialization + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + // MARK: - Setup UI + private func setupUI() { + backgroundColor = .clear + selectionStyle = .none + + // filling screen with contents + contentView.addSubview(containerView) + containerView.addSubview(mainStackView) + + mainStackView.addArrangedSubview(titleLabel) + mainStackView.addArrangedSubview(infoStackView) + + infoStackView.addArrangedSubview(datesStackView) + infoStackView.addArrangedSubview(moonAndOriginStackView) + + moonAndOriginStackView.addArrangedSubview(moonStackView) + moonAndOriginStackView.addArrangedSubview(originStackView) + + moonStackView.addArrangedSubview(moonIconImageView) + moonStackView.addArrangedSubview(ZHRLabel) + + datesStackView.addArrangedSubview(dateBeginLabel) + datesStackView.addArrangedSubview(datePeakLabel) + datesStackView.addArrangedSubview(dateEndLabel) + + originStackView.addArrangedSubview(originLabel) + + // main container + containerView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0)) + } + + // main stackview in main container + mainStackView.snp.makeConstraints { make in + make.leading.top.equalToSuperview().offset(12) + make.trailing.equalToSuperview().offset(-16) + } + } + + // MARK: - Configure cell + func configure(with shower: MeteorShower) { + titleLabel.text = shower.name + dateBeginLabel.text = "Begins: \(shower.formattedBeginDate)" + datePeakLabel.text = "Peak: \(shower.formattedPeakDate)" + dateEndLabel.text = "Ends: \(shower.formattedEndDate)" + ZHRLabel.text = "\(shower.formattedZHR)" + originLabel.text = """ +Parent body: + \(shower.parentBodyLabel) +""" + + // Calculate moon phase for shower's peak date + let moonPhase = moonPhaseCalculator.getMoonPhase(date: shower.datePeak) + if let phase = MoonPhase(rawValue: moonPhase.phase) { + moonIconImageView.image = phase.icon + } + } +} diff --git a/MeteorShowers/Extensions/UIView+Ext.swift b/MeteorShowers/Extensions/UIView+Ext.swift new file mode 100644 index 0000000..a8a0a97 --- /dev/null +++ b/MeteorShowers/Extensions/UIView+Ext.swift @@ -0,0 +1,25 @@ +// +// UIView+Ext.swift +// MeteorShowers +// +// Created by Vic on 26.01.2025. +// + +import UIKit +import SnapKit + +extension UIView { + + func snapKitPin(to superView: UIView) { + self.snp.makeConstraints({ + $0.edges.equalToSuperview() + }) + } +} + + +extension UIView { + class func fromNib() -> T { + return Bundle(for: T.self).loadNibNamed(String(describing: T.self), owner: nil, options: nil)![0] as! T + } +} diff --git a/MeteorShowers/Models/AstronomicalCalculations.swift b/MeteorShowers/Models/AstronomicalCalculations.swift new file mode 100644 index 0000000..59d2f61 --- /dev/null +++ b/MeteorShowers/Models/AstronomicalCalculations.swift @@ -0,0 +1,158 @@ +// +// MoonPhase.swift +// MeteorShowers +// +// Created by Vic on 28.01.2025. +// + +import Foundation +import CoreLocation +import UIKit + +enum MoonPhase: String, CaseIterable { + case newMoon = "New Moon" + case waxingCrescent = "Waxing Crescent" + case firstQuarter = "First Quarter" + case waxingGibbous = "Waxing Gibbous" + case fullMoon = "Full Moon" + case waningGibbous = "Waning Gibbous" + case lastQuarter = "Last Quarter" + case waningCrescent = "Waning Crescent" + + // method gets icon from SF Symbols + var iconName: String { + switch self { + case .newMoon: return "moonphase.new.moon" + case .waxingCrescent: return "moonphase.waxing.crescent" + case .firstQuarter: return "moonphase.first.quarter" + case .waxingGibbous: return "moonphase.waxing.gibbous" + case .fullMoon: return "moonphase.full.moon" + case .waningGibbous: return "moonphase.waning.gibbous" + case .lastQuarter: return "moonphase.last.quarter" + case .waningCrescent: return "moonphase.waning.crescent" + } + } + + // method getting icon and convert it to UIImage + var icon: UIImage? { + return UIImage(systemName: self.iconName) + } +} + +struct MoonPhaseCalculation { + // Known new moon date for better accuracy + private let knownNewMoon: Date = { + var components = DateComponents() + components.year = 2024 + components.month = 12 + components.day = 31 + components.hour = 3 + components.minute = 27 + components.second = 0 + return Calendar(identifier: .gregorian).date(from: components)! + }() + + private let synodicMonth: Double = 29.53058867 // average duration of synodic month + + func getMoonPhase(date: Date = Date()) -> (phase: String, illumination: Double, age: Double) { + let elapsedTime = date.timeIntervalSince(knownNewMoon) / 86400.0 // convert to days + let moonAge = elapsedTime.truncatingRemainder(dividingBy: synodicMonth) + let normalizedAge = moonAge < 0 ? moonAge + synodicMonth : moonAge + + // Calculate illumination using the normalized age + let angleInRadians = 2 * .pi * normalizedAge / synodicMonth + let illumination = ((1 - cos(angleInRadians)) / 2) * 100 + + let phase: String + switch normalizedAge { + case 0..<1.84: + phase = MoonPhase.newMoon.rawValue + case 1.84..<5.53: + phase = MoonPhase.waxingCrescent.rawValue + case 5.53..<9.22: + phase = MoonPhase.firstQuarter.rawValue + case 9.22..<12.91: + phase = MoonPhase.waxingGibbous.rawValue + case 12.91..<16.61: + phase = MoonPhase.fullMoon.rawValue + case 16.61..<20.30: + phase = MoonPhase.waningGibbous.rawValue + case 20.30..<23.99: + phase = MoonPhase.lastQuarter.rawValue + default: + phase = MoonPhase.waningCrescent.rawValue + } + + return (phase: phase, illumination: illumination, age: normalizedAge) + } +} + +struct SunRiseSetCalc { + func calculateSunriseSunset(for date: Date = Date(), at location: CLLocation) -> (sunrise: Date?, sunset: Date?) { + // Get the start of day in local timezone + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: date) + + // Constants + let latitude = location.coordinate.latitude + let longitude = location.coordinate.longitude + let dayOfYear = calendar.ordinality(of: .day, in: .year, for: date) ?? 1 + + // Calculate solar declination + let gamma = 2.0 * .pi / 365.0 * Double(dayOfYear - 1) + let declination = 0.006918 - 0.399912 * cos(gamma) + 0.070257 * sin(gamma) - 0.006758 * cos(2 * gamma) + 0.000907 * sin(2 * gamma) - 0.002697 * cos(3 * gamma) + 0.00148 * sin(3 * gamma) + + // Calculate time offset + let eqTime = 229.18 * (0.000075 + 0.001868 * cos(gamma) - 0.032077 * sin(gamma) - 0.014615 * cos(2 * gamma) - 0.040849 * sin(2 * gamma)) + + // Calculate solar noon (in minutes from midnight UTC) + let solarNoon = (720 - 4.0 * longitude - eqTime) / 1440.0 * 24.0 // Convert to hours + + // Calculate hour angle + let ha = acos(cos(90.833 * .pi / 180) / (cos(latitude * .pi / 180) * cos(declination)) - tan(latitude * .pi / 180) * tan(declination)) + let haHours = ha * 180.0 / .pi / 15.0 + + // Calculate sunrise and sunset times (in hours UTC) + let sunriseHour = (solarNoon - haHours) + let sunsetHour = (solarNoon + haHours) + + // Convert hours to date components + var riseComponents = calendar.dateComponents([.year, .month, .day], from: startOfDay) + riseComponents.hour = Int(floor(sunriseHour)) + riseComponents.minute = Int((sunriseHour.truncatingRemainder(dividingBy: 1.0) * 60.0).rounded()) + + var setComponents = calendar.dateComponents([.year, .month, .day], from: startOfDay) + setComponents.hour = Int(floor(sunsetHour)) + setComponents.minute = Int((sunsetHour.truncatingRemainder(dividingBy: 1.0) * 60.0).rounded()) + + // Create UTC dates + var utcCalendar = Calendar(identifier: .gregorian) + utcCalendar.timeZone = TimeZone(secondsFromGMT: 0)! + + guard let sunriseUTC = utcCalendar.date(from: riseComponents), + let sunsetUTC = utcCalendar.date(from: setComponents) else { + return (nil, nil) + } + + // Convert to local time + let localSunrise = calendar.date(byAdding: .second, value: TimeZone.current.secondsFromGMT(), to: sunriseUTC) + let localSunset = calendar.date(byAdding: .second, value: TimeZone.current.secondsFromGMT(), to: sunsetUTC) + + print(""" + 🌅 Debug Sun Calculation: + Location: \(latitude)°N, \(longitude)°E + Day of Year: \(dayOfYear) + Solar Declination: \(declination * 180.0 / .pi)° + Equation of Time: \(eqTime) minutes + Solar Noon (UTC): \(solarNoon) hours + Hour Angle: \(haHours) hours + Sunrise Hour (UTC): \(sunriseHour) hours + Sunset Hour (UTC): \(sunsetHour) hours + Local Timezone Offset: \(TimeZone.current.secondsFromGMT()/3600) hours + Final Local Sunrise: \(localSunrise ?? Date()) + Final Local Sunset: \(localSunset ?? Date()) + """) + + return (sunrise: localSunrise, sunset: localSunset) + } +} diff --git a/MeteorShowers/Models/LocationManager.swift b/MeteorShowers/Models/LocationManager.swift new file mode 100644 index 0000000..99894c7 --- /dev/null +++ b/MeteorShowers/Models/LocationManager.swift @@ -0,0 +1,69 @@ + +import Foundation +import CoreLocation + +protocol LocationManagerDelegate: AnyObject { + func locationManager(_ manager: LocationManager, didUpdateLocation location: CLLocation) + func locationManager(_ manager: LocationManager, didFailWithError error: Error) +} + +final class LocationManager: NSObject { + static let shared = LocationManager() + private let locationManager = CLLocationManager() + weak var delegate: LocationManagerDelegate? + var currentLocation: CLLocation? + + private override init() { + super.init() + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyBest + } + + func requestLocationUpdates() { + print("🌍 Location: Requesting authorization...") + locationManager.requestWhenInUseAuthorization() + } +} + +extension LocationManager: CLLocationManagerDelegate { + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + print("🌍 Location: Authorization status changed to: \(manager.authorizationStatus.rawValue)") + switch manager.authorizationStatus { + case .authorizedWhenInUse, .authorizedAlways: + print("🌍 Location: Authorized, starting location updates") + locationManager.startUpdatingLocation() + case .denied, .restricted: + print("🌍 Location: Access denied or restricted") + let error = NSError(domain: "LocationManager", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Location access denied"]) + delegate?.locationManager(self, didFailWithError: error) + case .notDetermined: + print("🌍 Location: Authorization not determined yet") + break + @unknown default: + print("🌍 Location: Unknown authorization status") + break + } + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + currentLocation = location + + print(""" + 🌍 Location Update Received: + Latitude: \(location.coordinate.latitude) + Longitude: \(location.coordinate.longitude) + Accuracy: \(location.horizontalAccuracy)m + Timestamp: \(location.timestamp) + """) + + delegate?.locationManager(self, didUpdateLocation: location) + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + print("🌍 Location Error: \(error.localizedDescription)") + delegate?.locationManager(self, didFailWithError: error) + } +} diff --git a/MeteorShowers/Models/MeteorShower.swift b/MeteorShowers/Models/MeteorShower.swift new file mode 100644 index 0000000..8501019 --- /dev/null +++ b/MeteorShowers/Models/MeteorShower.swift @@ -0,0 +1,45 @@ +// +// MeteorShower.swift +// MeteorShowers +// +// Created by Vic on 26.01.2025. +// + +import Foundation + +struct MeteorShower { + let name: String + let dateBegin: Date + let datePeak: Date + let dateEnd: Date + let zhr: Int + let speed: Int // optional you might add this value, but it's average, I don't see any point off adding it + let parentBody: String + + // Formatted string getters + var formattedBeginDate: String { + let formatter = DateFormatter() + formatter.dateFormat = "dd MMM yyyy" + return formatter.string(from: dateBegin) + } + + var formattedPeakDate: String { + let formatter = DateFormatter() + formatter.dateFormat = "dd MMM yyyy" + return formatter.string(from: datePeak) + } + + var formattedEndDate: String { + let formatter = DateFormatter() + formatter.dateFormat = "dd MMM yyyy" + return formatter.string(from: dateEnd) + } + + var parentBodyLabel: String { + "\(parentBody)" + } + + var formattedZHR: String { + "ZHR: \(zhr)" + } +} diff --git a/MeteorShowers/Models/RemindersManager.swift b/MeteorShowers/Models/RemindersManager.swift new file mode 100644 index 0000000..9a4ea88 --- /dev/null +++ b/MeteorShowers/Models/RemindersManager.swift @@ -0,0 +1,95 @@ +import Foundation +import UserNotifications + +final class RemindersManager { + static let shared = RemindersManager() + private let userDefaults = UserDefaults.standard + private let reminderKey = "meteorShowerReminders" + + private init() {} + + // MARK: - Data Structure + struct ReminderInfo: Codable { + let showerName: String + let peakDate: Date + let notificationDate: Date + let identifier: String + } + + // MARK: - Public Methods + func saveReminder(for shower: MeteorShower) { + let identifier = "meteorShower-\(shower.name)" + let notificationDate = Calendar.current.date(byAdding: .day, value: -3, to: shower.datePeak)! + + let reminderInfo = ReminderInfo( + showerName: shower.name, + peakDate: shower.datePeak, + notificationDate: notificationDate, + identifier: identifier + ) + + var reminders = getAllReminders() + reminders.append(reminderInfo) + saveReminders(reminders) + } + + func removeReminder(for shower: MeteorShower) { + let identifier = "meteorShower-\(shower.name)" + var reminders = getAllReminders() + reminders.removeAll { $0.identifier == identifier } + saveReminders(reminders) + + // Also remove from notification center + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier]) + } + + func hasReminder(for shower: MeteorShower) -> Bool { + let reminders = getAllReminders() + return reminders.contains { $0.showerName == shower.name } + } + + func restoreAllReminders() { + let reminders = getAllReminders() + + for reminder in reminders { + // Skip if notification date is in the past + if reminder.notificationDate < Date() { + continue + } + + let content = UNMutableNotificationContent() + content.title = "Upcoming Meteor Shower!" + content.body = "\(reminder.showerName) meteor shower peaks in 3 days! Get ready for the show!" + content.sound = .default + + let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: reminder.notificationDate) + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) + + let request = UNNotificationRequest( + identifier: reminder.identifier, + content: content, + trigger: trigger + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Error restoring notification: \(error)") + } + } + } + } + + // MARK: - Private Methods + private func getAllReminders() -> [ReminderInfo] { + guard let data = userDefaults.data(forKey: reminderKey), + let reminders = try? JSONDecoder().decode([ReminderInfo].self, from: data) else { + return [] + } + return reminders + } + + private func saveReminders(_ reminders: [ReminderInfo]) { + guard let data = try? JSONEncoder().encode(reminders) else { return } + userDefaults.set(data, forKey: reminderKey) + } +} diff --git a/MeteorShowers/Models/Showers.swift b/MeteorShowers/Models/Showers.swift new file mode 100644 index 0000000..ce8e7dc --- /dev/null +++ b/MeteorShowers/Models/Showers.swift @@ -0,0 +1,167 @@ +import Foundation + +final class Showers { + // MARK: - Singleton + static let shared = Showers() + private init() {} + + // MARK: - Date Formatter + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "dd.MM.yyyy" + return formatter + }() + + // MARK: - Meteor Showers Data + private lazy var showers: [MeteorShower] = { + [ + MeteorShower( + name: "Quadrantids", + dateBegin: dateFormatter.date(from: "28.12.2024")!, + datePeak: dateFormatter.date(from: "03.01.2025")!, + dateEnd: dateFormatter.date(from: "12.01.2025")!, + zhr: 80, + speed: 41, + parentBody: "2003 EH1" + ), + MeteorShower( + name: "Alpha Centaurids", + dateBegin: dateFormatter.date(from: "31.01.2025")!, + datePeak: dateFormatter.date(from: "08.02.2025")!, + dateEnd: dateFormatter.date(from: "20.02.2025")!, + zhr: 6, + speed: 58, + parentBody: "Unknown" + ), + MeteorShower( + name: "Lyrids", + dateBegin: dateFormatter.date(from: "14.04.2025")!, + datePeak: dateFormatter.date(from: "22.04.2025")!, + dateEnd: dateFormatter.date(from: "30.04.2025")!, + zhr: 18, + speed: 49, + parentBody: "C/1861 G1" + ), + MeteorShower( + name: "Eta Aquariids", + dateBegin: dateFormatter.date(from: "19.04.2025")!, + datePeak: dateFormatter.date(from: "06.05.2025")!, + dateEnd: dateFormatter.date(from: "28.05.2025")!, + zhr: 50, + speed: 66, + parentBody: "1P/Halley" + ), + MeteorShower( + name: "Daytime Arietids", + dateBegin: dateFormatter.date(from: "14.05.2025")!, + datePeak: dateFormatter.date(from: "07.06.2025")!, + dateEnd: dateFormatter.date(from: "24.06.2025")!, + zhr: 30, + speed: 38, + parentBody: "Unknown" + ), + MeteorShower( + name: "Southern Delta Aquariids", + dateBegin: dateFormatter.date(from: "12.07.2025")!, + datePeak: dateFormatter.date(from: "31.07.2025")!, + dateEnd: dateFormatter.date(from: "23.08.2025")!, + zhr: 25, + speed: 41, + parentBody: "96P/Machholz" + ), + MeteorShower( + name: "Perseids", + dateBegin: dateFormatter.date(from: "17.07.2025")!, + datePeak: dateFormatter.date(from: "12.08.2025")!, + dateEnd: dateFormatter.date(from: "24.08.2025")!, + zhr: 100, + speed: 59, + parentBody: "109P/Swift-Tuttle" + ), + MeteorShower( + name: "Orionids", + dateBegin: dateFormatter.date(from: "02.10.2025")!, + datePeak: dateFormatter.date(from: "21.10.2025")!, + dateEnd: dateFormatter.date(from: "07.11.2025")!, + zhr: 20, + speed: 66, + parentBody: "1P/Halley" + ), + MeteorShower( + name: "Southern Taurids", + dateBegin: dateFormatter.date(from: "20.09.2025")!, + datePeak: dateFormatter.date(from: "05.11.2025")!, + dateEnd: dateFormatter.date(from: "20.11.2025")!, + zhr: 7, + speed: 27, + parentBody: "2P/Encke" + ), + MeteorShower( + name: "Northern Taurids", + dateBegin: dateFormatter.date(from: "20.10.2025")!, + datePeak: dateFormatter.date(from: "12.11.2025")!, + dateEnd: dateFormatter.date(from: "10.12.2025")!, + zhr: 5, + speed: 29, + parentBody: "2P/Encke" + ), + MeteorShower( + name: "Leonids", + dateBegin: dateFormatter.date(from: "06.11.2025")!, + datePeak: dateFormatter.date(from: "17.11.2025")!, + dateEnd: dateFormatter.date(from: "30.11.2025")!, + zhr: 10, + speed: 71, + parentBody: "55P/Tempel-Tuttle" + ), + MeteorShower( + name: "Geminids", + dateBegin: dateFormatter.date(from: "04.12.2025")!, + datePeak: dateFormatter.date(from: "14.12.2025")!, + dateEnd: dateFormatter.date(from: "20.12.2025")!, + zhr: 150, + speed: 35, + parentBody: "3200 Phaethon" + ), + MeteorShower( + name: "Ursids", + dateBegin: dateFormatter.date(from: "17.12.2025")!, + datePeak: dateFormatter.date(from: "22.12.2025")!, + dateEnd: dateFormatter.date(from: "26.12.2025")!, + zhr: 10, + speed: 33, + parentBody: "8P/Tuttle" + ) + ] + }() + + // MARK: - Public Methods + + /// Returns all meteor showers + func getAllShowers() -> [MeteorShower] { + showers + } + + /// Returns current or upcoming meteor shower + func getCurrentShower() -> MeteorShower? { + let currentDate = Date() + return showers.first { shower in + (shower.dateBegin...shower.dateEnd).contains(currentDate) + } ?? getNextShower() + } + + /// Returns the next upcoming meteor shower + func getNextShower() -> MeteorShower? { + let currentDate = Date() + return showers.first { shower in + shower.dateBegin > currentDate + } + } + + /// Returns meteor shower for specific date + func getShower(for date: Date) -> MeteorShower? { + showers.first { shower in + (shower.dateBegin...shower.dateEnd).contains(date) + } + } +} diff --git a/MeteorShowers/Models/WeatherCondition.swift b/MeteorShowers/Models/WeatherCondition.swift new file mode 100644 index 0000000..ba9f56a --- /dev/null +++ b/MeteorShowers/Models/WeatherCondition.swift @@ -0,0 +1,69 @@ +import Foundation + +enum WeatherCondition: Int { + case clearSky = 0 + case mainlyClear = 1 + case partlyCloudy = 2 + case overcast = 3 + case fog = 45 + case depositingRimeFog = 48 + case drizzleLight = 51 + case drizzleModerate = 53 + case drizzleDense = 55 + case freezingDrizzleLight = 56 + case freezingDrizzleDense = 57 + case rainSlight = 61 + case rainModerate = 63 + case rainHeavy = 65 + case freezingRainLight = 66 + case freezingRainHeavy = 67 + case snowSlight = 71 + case snowModerate = 73 + case snowHeavy = 75 + case snowGrains = 77 + case rainShowers = 80 + case rainShowersHeavy = 82 + case snowShowers = 85 + case thunderstorm = 95 + case thunderstormHailSlight = 96 + case thunderstormHailHeavy = 99 + + var systemIconName: String { + switch self { + case .clearSky: + return "sun.max.fill" + case .mainlyClear: + return "sun.max" + case .partlyCloudy: + return "cloud.sun.fill" + case .overcast: + return "cloud.fill" + case .fog, .depositingRimeFog: + return "cloud.fog.fill" + case .drizzleLight, .drizzleModerate, .drizzleDense: + return "cloud.drizzle.fill" + case .freezingDrizzleLight, .freezingDrizzleDense: + return "cloud.sleet.fill" + case .rainSlight, .rainModerate: + return "cloud.rain.fill" + case .rainHeavy: + return "cloud.heavyrain.fill" + case .freezingRainLight, .freezingRainHeavy: + return "cloud.hail.fill" + case .snowSlight, .snowModerate: + return "cloud.snow.fill" + case .snowHeavy, .snowGrains: + return "cloud.snow.circle.fill" + case .rainShowers: + return "cloud.rain.fill" + case .rainShowersHeavy: + return "cloud.heavyrain.fill" + case .snowShowers: + return "cloud.snow.fill" + case .thunderstorm: + return "cloud.bolt.fill" + case .thunderstormHailSlight, .thunderstormHailHeavy: + return "cloud.bolt.rain.fill" + } + } +} diff --git a/MeteorShowers/Models/WeatherData.swift b/MeteorShowers/Models/WeatherData.swift new file mode 100644 index 0000000..1027501 --- /dev/null +++ b/MeteorShowers/Models/WeatherData.swift @@ -0,0 +1,23 @@ +import Foundation + +struct WeatherData: Decodable { + let current: Current + + struct Current: Decodable { + let temperature: Double + let weatherCode: Int + + enum CodingKeys: String, CodingKey { + case temperature = "temperature_2m" + case weatherCode = "weather_code" + } + + var condition: WeatherCondition? { + return WeatherCondition(rawValue: weatherCode) + } + } + + enum CodingKeys: String, CodingKey { + case current + } +} diff --git a/MeteorShowers/Networking/NetworkManager.swift b/MeteorShowers/Networking/NetworkManager.swift new file mode 100644 index 0000000..ecbf6b9 --- /dev/null +++ b/MeteorShowers/Networking/NetworkManager.swift @@ -0,0 +1,46 @@ +// +// NetworkManager.swift +// MeteorShowers +// +// Created by Vic on 31.01.2025. +// + +import Foundation +import Alamofire +import CoreLocation +import OpenMeteoSdk + +enum NetworkError: Error { + case invalidResponse + case decodingError + case apiError(String) +} + +final class NetworkManager { + static let shared = NetworkManager() + private init() {} + + private let baseURL = "https://api.open-meteo.com/v1/forecast" + + func fetchWeather(for location: CLLocation) async throws -> WeatherData { + let url = URL(string: "\(baseURL)?latitude=\(location.coordinate.latitude)&longitude=\(location.coordinate.longitude)¤t=temperature_2m,weather_code&timezone=auto&format=flatbuffers")! + + do { + let responses = try await WeatherApiResponse.fetch(url: url) + guard let response = responses.first else { + throw NetworkError.invalidResponse + } + + let current = response.current! + + return WeatherData( + current: .init( + temperature: Double(current.variables(at: 0)!.value), + weatherCode: Int(current.variables(at: 1)!.value) + ) + ) + } catch { + throw NetworkError.apiError(error.localizedDescription) + } + } +} diff --git a/MeteorShowers/ViewControllers/MainViewController.swift b/MeteorShowers/ViewControllers/MainViewController.swift new file mode 100644 index 0000000..ddea03a --- /dev/null +++ b/MeteorShowers/ViewControllers/MainViewController.swift @@ -0,0 +1,111 @@ +// +// ViewController.swift +// MeteorShowers +// +// Created by Vic on 26.01.2025. +// + +import UIKit +import SnapKit + +final class MainViewController: UIViewController { + + // MARK: - Properties + private let showers = Showers.shared.getAllShowers() + + // MARK: - Main stack + private lazy var mainStackView: UIStackView = { + var stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 8 + stackView.alignment = .fill + stackView.distribution = .fill + return stackView + }() + + // MARK: - creating view with uiview extension + private lazy var topView: TopView = .fromNib() + + // MARK: - UITableView + private lazy var tableView: UITableView = { + let table = UITableView() + table.backgroundColor = .clear + table.separatorStyle = .none + table.showsVerticalScrollIndicator = false + return table + }() + + // MARK: - viewDidLoad + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .darkMidnight + setupUI() + } +} + +// MARK: - Configuring UI +private extension MainViewController { + + func setupUI() { + setDelegates() + + view.addSubview(mainStackView) + mainStackView.addArrangedSubview(topView) + mainStackView.addArrangedSubview(tableView) + + // MARK: - Cell registration + tableView.register(MeteorShowerTableViewCell.self, forCellReuseIdentifier: "MeteorShowerTableViewCell") + + // constraints + mainStackView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.trailing.equalToSuperview().offset(-16) + make.bottom.equalToSuperview() + make.top.equalTo(view.safeAreaLayoutGuide.snp.top) + } + + topView.snp.makeConstraints { make in + make.height.equalTo(100) + } + + tableView.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + } + } +} + +// MARK: - UITableViewDelegates +extension MainViewController: UITableViewDelegate, UITableViewDataSource { + + func setDelegates() { + tableView.delegate = self + tableView.dataSource = self + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return showers.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "MeteorShowerTableViewCell", for: indexPath) as! MeteorShowerTableViewCell + let shower = showers[indexPath.row] + cell.configure(with: shower) + return cell + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return 115 + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let detailViewController = ShowerDetailViewController() + let selectedShower = showers[indexPath.row] + detailViewController.configure(with: selectedShower) + + if let navigationController = navigationController { + navigationController.pushViewController(detailViewController, animated: true) + } + + tableView.deselectRow(at: indexPath, animated: true) + } +} diff --git a/MeteorShowers/ViewControllers/ShowerDetailViewController.swift b/MeteorShowers/ViewControllers/ShowerDetailViewController.swift new file mode 100644 index 0000000..9c15b25 --- /dev/null +++ b/MeteorShowers/ViewControllers/ShowerDetailViewController.swift @@ -0,0 +1,343 @@ +// +// ShowerDetailViewController.swift +// MeteorShowers +// +// Created by Vic on 29.01.2025. +// + +import UIKit +import SnapKit +import UserNotifications + +final class ShowerDetailViewController: UIViewController { + + // MARK: - Properties + private let moonPhaseCalculator = MoonPhaseCalculation() + private let remindersManager = RemindersManager.shared + private var currentShower: MeteorShower? + private var hasReminder = false + private lazy var reminderButton: UIBarButtonItem = { + let button = UIBarButtonItem( + image: UIImage(systemName: "bell.fill"), + style: .plain, + target: self, + action: #selector(reminderButtonTapped) + ) + button.tintColor = .systemGreen + return button + }() + + // MARK: - Main stack + lazy private var mainStackView: UIStackView = { + var stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 8 + stackView.alignment = .fill + stackView.distribution = .fillEqually + + return stackView + }() + + // MARK: - content stack + lazy private var imageStackView: UIStackView = { + var stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 8 + stackView.alignment = .center + stackView.distribution = .equalCentering + + return stackView + }() + + lazy private var infoStackView: UIStackView = { + var stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 8 + stackView.alignment = .fill + stackView.distribution = .fillEqually + + return stackView + }() + + lazy private var bodyPeakAndZHRStackView: UIStackView = { + var stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 8 + stackView.alignment = .leading + stackView.distribution = .fillEqually + + return stackView + }() + + lazy private var moonStackView: UIStackView = { + var stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 8 + stackView.alignment = .fill + stackView.distribution = .fillEqually + + return stackView + }() + + lazy private var descriptionStackView: UIStackView = { + var stackView = UIStackView() + stackView.axis = .horizontal + stackView.alignment = .fill + stackView.distribution = .fillEqually + + return stackView + }() + + lazy private var mainImageView: UIImageView = { + var imageView = UIImageView() + imageView.image = UIImage(named: "mock.jpeg") + imageView.contentMode = .scaleToFill + imageView.clipsToBounds = true + imageView.backgroundColor = .lightMidnight + imageView.layer.cornerRadius = 12 + return imageView + }() + + lazy private var notificationIcon: UIImageView = { + var imageView = UIImageView() + imageView.image = UIImage(systemName: "bell.fill") + imageView.tintColor = .systemGreen + imageView.contentMode = .scaleAspectFit + imageView.snp.makeConstraints { make in + make.height.width.equalTo(24) + } + return imageView + }() + + lazy private var peakLabel: UILabel = { + var label = UILabel() + label.font = .systemFont(ofSize: 17, weight: .bold) + label.textColor = .lightGray + label.textAlignment = .center + return label + }() + + lazy private var ZHRLabel: UILabel = { + var label = UILabel() + label.font = .systemFont(ofSize: 18, weight: .bold) + label.textColor = .lightGray + label.textAlignment = .center + return label + }() + + lazy private var originLabel: UILabel = { + var label = UILabel() + label.font = .systemFont(ofSize: 14, weight: .thin) + label.textColor = .lightGray + label.textAlignment = .center + return label + }() + + lazy private var moonPhaseLabel: UILabel = { + var label = UILabel() + label.font = .systemFont(ofSize: 16, weight: .bold) + label.textColor = .lightGray + label.textAlignment = .center + return label + }() + + lazy private var moonPhaseIcon: UIImageView = { + var imageView = UIImageView() + imageView.image = UIImage(systemName: "moon") + imageView.tintColor = .lightGray + imageView.contentMode = .scaleAspectFit + return imageView + }() + + lazy private var descriptionLabel: UILabel = { + var label = UILabel() + label.font = .systemFont(ofSize: 14, weight: .bold) + label.textAlignment = .center + label.text = "description" + return label + }() + + + // MARK: - life cycle method + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .darkMidnight + setupUI() + setupNavigationBar() + } + +} + +// MARK: - Setup UI +extension ShowerDetailViewController { + func setupUI() { + view.addSubview(mainStackView) + + mainStackView.addArrangedSubview(imageStackView) + mainStackView.addArrangedSubview(infoStackView) + mainStackView.addArrangedSubview(descriptionStackView) + + imageStackView.addArrangedSubview(mainImageView) + + infoStackView.addArrangedSubview(bodyPeakAndZHRStackView) + infoStackView.addArrangedSubview(moonStackView) + + bodyPeakAndZHRStackView.addArrangedSubview(peakLabel) + bodyPeakAndZHRStackView.addArrangedSubview(ZHRLabel) + bodyPeakAndZHRStackView.addArrangedSubview(originLabel) + + moonStackView.addArrangedSubview(moonPhaseIcon) + moonStackView.addArrangedSubview(moonPhaseLabel) + + mainStackView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(16) + make.leading.trailing.equalToSuperview().inset(16) + } + + mainImageView.snp.makeConstraints { make in + make.height.equalTo(210) + make.width.equalTo(imageStackView.snp.width) + } + + imageStackView.snp.makeConstraints { make in + make.height.equalTo(210) + } + + moonPhaseIcon.snp.makeConstraints { make in + make.height.width.equalTo(48) + make.centerX.equalToSuperview() + } + } + + private func setupNavigationBar() { + navigationItem.rightBarButtonItem = reminderButton + navigationItem.largeTitleDisplayMode = .never + updateReminderButtonState() + } + + private func updateReminderButtonState() { + guard let shower = currentShower else { return } + + let hasReminder = remindersManager.hasReminder(for: shower) + self.hasReminder = hasReminder + reminderButton.image = UIImage(systemName: hasReminder ? "bell.slash.fill" : "bell.fill") + } + + @objc private func reminderButtonTapped() { + if hasReminder { + removeNotification() + } else { + requestNotificationPermission() + } + } + + private func removeNotification() { + guard let shower = currentShower else { return } + + remindersManager.removeReminder(for: shower) + + // Update UI and show feedback + hasReminder = false + reminderButton.image = UIImage(systemName: "bell.fill") + + let alert = UIAlertController( + title: "Reminder Removed", + message: "The reminder for \(shower.name) meteor shower has been removed.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } + + private func requestNotificationPermission() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, error in + if granted { + DispatchQueue.main.async { + self?.scheduleNotification() + } + } else { + // Handle case when permission is not granted + print("Notification permission denied") + } + } + } + + private func scheduleNotification() { + guard let shower = currentShower else { return } + + let content = UNMutableNotificationContent() + content.title = "Upcoming Meteor Shower!" + content.body = "\(shower.name) meteor shower peaks in 3 days! Get ready for the show!" + content.sound = .default + + // Calculate notification date (3 days before peak) + let peakDate = shower.datePeak + let notificationDate = Calendar.current.date(byAdding: .day, value: -3, to: peakDate)! + let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: notificationDate) + + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) + let request = UNNotificationRequest( + identifier: "meteorShower-\(shower.name)", + content: content, + trigger: trigger + ) + + UNUserNotificationCenter.current().add(request) { [weak self] error in + DispatchQueue.main.async { + if let error = error { + print("Error scheduling notification: \(error)") + self?.showNotificationAlert(success: false) + } else { + print("Notification scheduled for \(shower.name)") + // Save to persistent storage + self?.remindersManager.saveReminder(for: shower) + self?.hasReminder = true + self?.reminderButton.image = UIImage(systemName: "bell.slash.fill") + self?.showNotificationAlert(success: true) + } + } + } + } + + private func showNotificationAlert(success: Bool) { + let title = success ? "Reminder Set!" : "Error" + let message = success ? + "You'll be notified 3 days before the \(currentShower?.name ?? "") meteor shower peaks." : + "Failed to set reminder. Please check if notifications are enabled in Settings." + + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } +} + +// MARK: - Fill with contents + +extension ShowerDetailViewController { + func configure(with shower: MeteorShower) { + currentShower = shower + navigationItem.title = shower.name + peakLabel.text = "Peak: \(shower.formattedPeakDate)" + ZHRLabel.text = "\(shower.formattedZHR)" + originLabel.text = "Parent body: \(shower.parentBodyLabel)" + + // Calculate moon phase for shower's peak date + let moonPhase = moonPhaseCalculator.getMoonPhase(date: shower.datePeak) + if let phase = MoonPhase(rawValue: moonPhase.phase) { + moonPhaseIcon.image = phase.icon + moonPhaseLabel.text = moonPhase.phase + } + + // Check if reminder exists + updateReminderButtonState() + } + + private func updateMoonPhase() { + let moonInfo = moonPhaseCalculator.getMoonPhase() + updateMoonPhaseUI(with: moonInfo) + } + + private func updateMoonPhaseUI(with moonInfo: (phase: String, illumination: Double, age: Double)) { + moonPhaseLabel.text = moonInfo.phase + } +} diff --git a/MeteorShowers/Views/.DS_Store b/MeteorShowers/Views/.DS_Store new file mode 100644 index 0000000..96e344d Binary files /dev/null and b/MeteorShowers/Views/.DS_Store differ diff --git a/MeteorShowers/Views/TopView.swift b/MeteorShowers/Views/TopView.swift new file mode 100644 index 0000000..ab98ae2 --- /dev/null +++ b/MeteorShowers/Views/TopView.swift @@ -0,0 +1,141 @@ +// +// TopView.swift +// MeteorShowers +// +// Created by Vic on 26.01.2025. +// + +import UIKit +import CoreLocation + +final class TopView: UIView { + + // MARK: - Properties + private let moonPhaseCalculator = MoonPhaseCalculation() + private let sunCalculator = SunRiseSetCalc() + private let locationManager = LocationManager.shared + private lazy var dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + formatter.timeZone = TimeZone(secondsFromGMT: 0) // Use UTC to avoid double timezone conversion + return formatter + }() + + // MARK: - UIs + @IBOutlet weak var sunriseTimeLabel: UILabel! + @IBOutlet weak var sunsetTimeLabel: UILabel! + + @IBOutlet weak var tempLabel: UILabel! + @IBOutlet weak var forecastIconImageView: UIImageView! + + @IBOutlet weak var moonPhaseIconImageView: UIImageView! + @IBOutlet weak var IlluminationLabel: UILabel! + @IBOutlet weak var moonPhaseLabel: UILabel! + + // MARK: - Lifecycle + override func awakeFromNib() { + super.awakeFromNib() + + setupUI() + setupLocation() + updateMoonPhase() + } + + // MARK: - Private Methods + private func setupUI() { + self.clipsToBounds = true + self.layer.cornerRadius = 9 + self.layer.masksToBounds = true + + // Set initial values + sunriseTimeLabel.text = "--:--" + sunsetTimeLabel.text = "--:--" + } + + private func setupLocation() { + locationManager.delegate = self + locationManager.requestLocationUpdates() + } + + private func updateMoonPhase() { + let moonInfo = moonPhaseCalculator.getMoonPhase() + updateMoonPhaseUI(with: moonInfo) + } + + private func updateMoonPhaseUI(with moonInfo: (phase: String, illumination: Double, age: Double)) { + moonPhaseLabel.text = moonInfo.phase + IlluminationLabel.text = String(format: "%.1f%%", moonInfo.illumination) + + if let moonPhase = MoonPhase.allCases.first(where: { $0.rawValue == moonInfo.phase }) { + moonPhaseIconImageView.image = moonPhase.icon + moonPhaseIconImageView.tintColor = .lightGray + } + } + + private func updateSunTimes(for location: CLLocation) { + print("🌅 Calculating sun times for location: \(location.coordinate.latitude), \(location.coordinate.longitude)") + + // Get today's date at midnight in current timezone + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + + let times = sunCalculator.calculateSunriseSunset(for: today, at: location) + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + if let sunrise = times.sunrise { + let timeString = self.dateFormatter.string(from: sunrise) + print("🌅 Sunrise calculated: \(timeString) (Date: \(sunrise))") + self.sunriseTimeLabel.text = timeString + } else { + print("🌅 No sunrise calculated") + self.sunriseTimeLabel.text = "No sunrise" + } + + if let sunset = times.sunset { + let timeString = self.dateFormatter.string(from: sunset) + print("🌅 Sunset calculated: \(timeString) (Date: \(sunset))") + self.sunsetTimeLabel.text = timeString + } else { + print("🌅 No sunset calculated") + self.sunsetTimeLabel.text = "No sunset" + } + } + } + + private func updateWeather(for location: CLLocation) { + Task { + do { + let weatherData = try await NetworkManager.shared.fetchWeather(for: location) + await MainActor.run { + tempLabel.text = String(format: "%.1f°C", weatherData.current.temperature) + if let condition = weatherData.current.condition { + forecastIconImageView.image = UIImage(systemName: condition.systemIconName) + } + } + } catch { + print("Error fetching weather: \(error)") + } + } + } +} + +// MARK: - LocationManagerDelegate +extension TopView: LocationManagerDelegate { + func locationManager(_ manager: LocationManager, didUpdateLocation location: CLLocation) { + DispatchQueue.main.async { [weak self] in + self?.updateSunTimes(for: location) + self?.updateWeather(for: location) + } + } + + func locationManager(_ manager: LocationManager, didFailWithError error: Error) { + DispatchQueue.main.async { [weak self] in + // Use Moscow coordinates as fallback + let moscowLocation = CLLocation(latitude: 55.7558, longitude: 37.6173) + self?.updateSunTimes(for: moscowLocation) + self?.updateWeather(for: moscowLocation) + } + } +} diff --git a/MeteorShowers/Views/TopView.xib b/MeteorShowers/Views/TopView.xib new file mode 100644 index 0000000..79bfd1b --- /dev/null +++ b/MeteorShowers/Views/TopView.xib @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/showers.pdf b/showers.pdf new file mode 100644 index 0000000..7d8c61a Binary files /dev/null and b/showers.pdf differ