add files

This commit is contained in:
VicSergeev 2026-06-12 14:34:37 +03:00
parent 30fe5fd5e1
commit d829c62c64
40 changed files with 2869 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

223
DEVELOPER_MANUAL.md Normal file
View file

@ -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

View file

@ -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 = "<group>";
};
/* 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 = "<group>";
};
57E9946C2D46517500912D66 /* Products */ = {
isa = PBXGroup;
children = (
57E9946B2D46517500912D66 /* MeteorShowers.app */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -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
}

View file

@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1610"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "57E9946A2D46517500912D66"
BuildableName = "MeteorShowers.app"
BlueprintName = "MeteorShowers"
ReferencedContainer = "container:MeteorShowers.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "57E9946A2D46517500912D66"
BuildableName = "MeteorShowers.app"
BlueprintName = "MeteorShowers"
ReferencedContainer = "container:MeteorShowers.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "CORESVG_VERBOSE"
value = "1"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "57E9946A2D46517500912D66"
BuildableName = "MeteorShowers.app"
BlueprintName = "MeteorShowers"
ReferencedContainer = "container:MeteorShowers.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>MeteorShowers.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>SnapKitPlayground (Playground).xcscheme</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>57E9946A2D46517500912D66</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>

BIN
MeteorShowers/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -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<UISceneSession>) {
// 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.
}
}

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We need your location to calculate accurate sunrise and sunset times for your area.</string>
<key>NSUserNotificationUsageDescription</key>
<string>We'll notify you 3 days before meteor shower peaks so you don't miss the show!</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
</dict>
</array>
</dict>
</dict>
</dict>
</plist>

View file

@ -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.
}
}

View file

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="bg_m" translatesAutoresizingMaskIntoConstraints="NO" id="Sz2-Em-jJh">
<rect key="frame" x="-169" y="-189" width="1130" height="1207"/>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
<color key="backgroundColor" systemColor="systemTealColor"/>
<constraints>
<constraint firstAttribute="trailing" secondItem="Sz2-Em-jJh" secondAttribute="trailing" constant="-568" id="FIX-0T-Jqg"/>
<constraint firstItem="Sz2-Em-jJh" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" constant="-189" id="LeO-LK-k6P"/>
<constraint firstItem="Sz2-Em-jJh" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" constant="-169" id="cku-nT-EkD"/>
<constraint firstAttribute="bottom" secondItem="Sz2-Em-jJh" secondAttribute="bottom" constant="-166" id="moU-Wv-Ms1"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="52.671755725190835" y="374.64788732394368"/>
</scene>
</scenes>
<resources>
<image name="bg_m" width="1508" height="1518"/>
<systemColor name="systemTealColor">
<color red="0.18823529410000001" green="0.69019607839999997" blue="0.78039215689999997" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View file

@ -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
}
}
}

View file

@ -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: UIView>() -> T {
return Bundle(for: T.self).loadNibNamed(String(describing: T.self), owner: nil, options: nil)![0] as! T
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)"
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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"
}
}
}

View file

@ -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
}
}

View file

@ -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)&current=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)
}
}
}

View file

@ -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)
}
}

View file

@ -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
}
}

BIN
MeteorShowers/Views/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -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)
}
}
}

View file

@ -0,0 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<capability name="Image references" minToolsVersion="12.0"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view clipsSubviews="YES" contentMode="scaleToFill" id="iN0-l3-epB" customClass="TopView" customModule="MeteorShowers" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="363" height="150"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" distribution="fillProportionally" spacing="3" translatesAutoresizingMaskIntoConstraints="NO" id="Lh3-TW-hc0" userLabel="Main Stack View">
<rect key="frame" x="8" y="8" width="347" height="134"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="fillEqually" translatesAutoresizingMaskIntoConstraints="NO" id="7fE-rz-sBk" userLabel="Sun Stack View">
<rect key="frame" x="0.0" y="0.0" width="115" height="134"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" distribution="fillEqually" translatesAutoresizingMaskIntoConstraints="NO" id="g89-qq-Cmg" userLabel="Rise Stack View">
<rect key="frame" x="0.0" y="0.0" width="115" height="67"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="JB1-to-8LG" userLabel="Icon Stack View">
<rect key="frame" x="0.0" y="0.0" width="57.333333333333336" height="67"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="DK8-fw-qyw">
<rect key="frame" x="0.0" y="2.3333333333333357" width="57.333333333333336" height="63.666666666666664"/>
<imageReference key="image" image="sunrise.fill" catalog="system" renderingMode="original"/>
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="small"/>
</imageView>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="tIs-S8-mpT" userLabel="Time Stack View">
<rect key="frame" x="57.333333333333329" y="0.0" width="57.666666666666671" height="67"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="07:02" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="dbT-fC-rK4">
<rect key="frame" x="0.0" y="0.0" width="57.666666666666664" height="67"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="17"/>
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" distribution="fillEqually" translatesAutoresizingMaskIntoConstraints="NO" id="bo6-lk-qjK" userLabel="Dusk Stack View">
<rect key="frame" x="0.0" y="67" width="115" height="67"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="6Ri-pq-LPg" userLabel="Icon Stack View">
<rect key="frame" x="0.0" y="0.0" width="57.333333333333336" height="67"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="KxC-DV-xtd">
<rect key="frame" x="0.0" y="2.3333333333333357" width="57.333333333333336" height="63.666666666666664"/>
<imageReference key="image" image="sunset.fill" catalog="system" renderingMode="original"/>
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="small"/>
</imageView>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="U5G-D2-6Nx" userLabel="Time Stack View">
<rect key="frame" x="57.333333333333329" y="0.0" width="57.666666666666671" height="67"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="18:13" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="mLN-r9-qnP">
<rect key="frame" x="0.0" y="0.0" width="57.666666666666664" height="67"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="17"/>
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
</subviews>
</stackView>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="fillEqually" translatesAutoresizingMaskIntoConstraints="NO" id="Rbp-Ks-0Jy" userLabel="Forecast Stack View">
<rect key="frame" x="118" y="0.0" width="52" height="134"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="9UP-Yn-lHd" userLabel="Temp Stack View">
<rect key="frame" x="0.0" y="0.0" width="52" height="67"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="+ 3 C" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="LsH-ur-UYD">
<rect key="frame" x="0.0" y="0.0" width="52" height="67"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="HBb-Qs-RY1" userLabel="Icon Stack View">
<rect key="frame" x="0.0" y="67" width="52" height="67"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="ZZk-Nb-Urp">
<rect key="frame" x="0.0" y="0.3333333333333357" width="52.666666666666671" height="66.666666666666657"/>
<imageReference key="image" image="cloud.sun.fill" catalog="system" renderingMode="original"/>
</imageView>
</subviews>
</stackView>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="fillEqually" translatesAutoresizingMaskIntoConstraints="NO" id="kxa-nk-kcN" userLabel="Moon Stack View">
<rect key="frame" x="173" y="0.0" width="174" height="134"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" distribution="fillEqually" translatesAutoresizingMaskIntoConstraints="NO" id="ljf-yW-EmY" userLabel="Info Stack View">
<rect key="frame" x="0.0" y="0.0" width="174" height="67"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="Aa7-Pz-BTs">
<rect key="frame" x="0.0" y="0.6666666666666643" width="87" height="65.666666666666686"/>
<imageReference key="image" image="moonphase.waning.crescent.inverse" catalog="system" renderingMode="original"/>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="11%" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="FvE-Tv-ohL">
<rect key="frame" x="87" y="0.0" width="87" height="67"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="17"/>
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="XRy-3E-eO3" userLabel="Phase Stack View">
<rect key="frame" x="0.0" y="67" width="174" height="67"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Waning Crescending" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="tci-qO-cjF">
<rect key="frame" x="0.0" y="0.0" width="174" height="67"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="14"/>
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
</subviews>
</stackView>
</subviews>
</stackView>
</subviews>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<color key="backgroundColor" name="light_midnight"/>
<constraints>
<constraint firstAttribute="bottom" secondItem="Lh3-TW-hc0" secondAttribute="bottom" constant="8" id="1lL-vv-QbN"/>
<constraint firstItem="Lh3-TW-hc0" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" constant="8" id="Wx3-1i-78V"/>
<constraint firstItem="Lh3-TW-hc0" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="8" id="o5I-3e-bEd"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="Lh3-TW-hc0" secondAttribute="trailing" constant="8" id="s3B-YN-M0u"/>
</constraints>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<connections>
<outlet property="IlluminationLabel" destination="FvE-Tv-ohL" id="yy2-E3-Bkw"/>
<outlet property="forecastIconImageView" destination="ZZk-Nb-Urp" id="PCh-Tj-ifl"/>
<outlet property="moonPhaseIconImageView" destination="Aa7-Pz-BTs" id="98K-4b-CJe"/>
<outlet property="moonPhaseLabel" destination="tci-qO-cjF" id="I07-lb-YyX"/>
<outlet property="sunriseTimeLabel" destination="dbT-fC-rK4" id="7rz-g9-6vt"/>
<outlet property="sunsetTimeLabel" destination="mLN-r9-qnP" id="MSN-1f-KaJ"/>
<outlet property="tempLabel" destination="LsH-ur-UYD" id="Eft-4N-OCI"/>
</connections>
<point key="canvasLocation" x="107.63358778625954" y="241.5492957746479"/>
</view>
</objects>
<resources>
<image name="cloud.sun.fill" catalog="system" width="128" height="101"/>
<image name="moonphase.waning.crescent.inverse" catalog="system" width="128" height="123"/>
<image name="sunrise.fill" catalog="system" width="128" height="104"/>
<image name="sunset.fill" catalog="system" width="128" height="104"/>
<namedColor name="light_midnight">
<color red="0.11673399925956369" green="0.19502729158678409" blue="0.36641927908376315" alpha="1" colorSpace="custom" customColorSpace="displayP3"/>
</namedColor>
</resources>
</document>

BIN
showers.pdf Normal file

Binary file not shown.