Merge branch 'main' into main
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
*.gif filter=lfs diff=lfs merge=lfs -text
|
||||
*.mov filter=lfs diff=lfs merge=lfs -text
|
||||
BIN
.github/assets/CircularLockScreenWidgets.png
vendored
Normal file
|
After Width: | Height: | Size: 692 KiB |
BIN
.github/assets/atoll-demo.gif
vendored
|
Before Width: | Height: | Size: 26 MiB After Width: | Height: | Size: 133 B |
3
.github/assets/lockscreen.gif
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:88e4c5da1649734ce4b9f02745e9c467df60166ebd8a187f09c452a100063220
|
||||
size 90875738
|
||||
3
.github/assets/timer.gif
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9bec19433c1bddad8bc9b86b2f61f9324a109e3469753625643531d6bbb3598f
|
||||
size 1189001
|
||||
3
.github/assets/timer.mov
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cdbf9a492037d5a53d3cd036f7030373a67ead87793139246290cf69c4b46777
|
||||
size 2128950
|
||||
2
.gitignore
vendored
|
|
@ -152,3 +152,5 @@ borrowing/
|
|||
MoveToSky.xcodeproj
|
||||
/MoveToSky/
|
||||
mew-notch/
|
||||
|
||||
/References/
|
||||
|
|
|
|||
71
CODE_OF_CONDUCT.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others’ private information, such as a physical or email address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [maintainer email or Discord]. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
**Community Impact**: A violation through a single incident or series of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
97
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<p align="center">
|
||||
<img src=".github/assets/atoll-logo.png" alt="Atoll logo" width="100">
|
||||
</p>
|
||||
|
||||
# Contributing to Atoll
|
||||
|
||||
Thank you for your interest in contributing to Atoll! We welcome contributions from everyone—developers, designers, testers, and documentation writers. Please read the following guidelines to help us maintain a collaborative and high-quality project.
|
||||
|
||||
## Table of Contents
|
||||
- [How to Contribute](#how-to-contribute)
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Coding Guidelines](#coding-guidelines)
|
||||
- [Design Contributions](#design-contributions)
|
||||
- [Documentation](#documentation)
|
||||
- [Code Review process](#code-review-process)
|
||||
- [Community & Support](#community--support)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Fork the repository** and clone your fork locally.
|
||||
2. **Create a feature branch** for your changes: `git switch -c feature/your-feature-name`
|
||||
3. **Make your changes** following the guidelines below.
|
||||
4. **Test your changes** to ensure they work as expected and do not break existing functionality.
|
||||
5. **Commit** with clear, descriptive messages.
|
||||
6. **Push** to your fork and submit a **pull request** (PR) to the `main` branch.
|
||||
7. **Participate in code review** and address any feedback.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
We are committed to fostering a welcoming and inclusive environment. Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before participating.
|
||||
|
||||
## Development Setup
|
||||
|
||||
- **Requirements:**
|
||||
- macOS Sonoma 14.0 or later
|
||||
- Xcode 15.0+ with Swift 5.9 toolchain
|
||||
- MacBook with a notch (for full feature testing)
|
||||
- **Clone the repo:**
|
||||
```bash
|
||||
git clone https://github.com/Ebullioscopic/Atoll.git
|
||||
cd Atoll
|
||||
open DynamicIsland.xcodeproj
|
||||
```
|
||||
- **Build & Run:**
|
||||
- Select your Mac as the run destination in Xcode.
|
||||
- Build and run (Cmd+R).
|
||||
- Grant any requested permissions.
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
- Ensure your branch is up to date with `main` before submitting a PR.
|
||||
- Provide a clear description of your changes and the motivation behind them.
|
||||
- Reference any related issues or discussions.
|
||||
- Add screenshots or screen recordings for UI changes.
|
||||
- Ensure the code builds without errors or warnings.
|
||||
- Respond promptly to review feedback.
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
- Follow Swift API Design Guidelines.
|
||||
- Use meaningful variable and function names.
|
||||
- Keep functions focused and concise.
|
||||
- Add inline documentation for public APIs and complex logic.
|
||||
- Write unit tests for new features where practical.
|
||||
- Avoid introducing unnecessary dependencies.
|
||||
|
||||
## Design Contributions
|
||||
|
||||
- Submit UI/UX improvements, mockups, or visual assets.
|
||||
- Maintain consistency with macOS Human Interface Guidelines.
|
||||
- Provide rationale for design decisions.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Improve user guides, API docs, and troubleshooting sections.
|
||||
- Translate documentation into other languages.
|
||||
- Clarify setup instructions and add usage examples.
|
||||
|
||||
## Code review process
|
||||
- All pull requests require review from project maintainers before merging.
|
||||
- Automated testing must pass via continuous integration workflows.
|
||||
- Changes should not significantly decrease test coverage without justification.
|
||||
- Breaking changes require major version updates following semantic versioning.
|
||||
|
||||
## Community & Support
|
||||
|
||||
- Join our [Discord community](https://discord.gg/zvjB4tJBkt) for discussion, support, and collaboration.
|
||||
- Be respectful and constructive in all interactions.
|
||||
|
||||
---
|
||||
|
||||
Thank you for helping make Atoll better!
|
||||
|
|
@ -59,7 +59,7 @@
|
|||
7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; };
|
||||
7EE1D73E2E6B1E1200997219 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; };
|
||||
F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; };
|
||||
F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DynamicIsland.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
|
|
@ -144,7 +144,7 @@
|
|||
F54406E82CD4987A00C7AFD9 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */,
|
||||
F54406E72CD4987A00C7AFD9 /* Atoll.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -182,7 +182,7 @@
|
|||
7E61181E2E24071E0050006C /* SkyLightWindow */,
|
||||
);
|
||||
productName = DynamicIsland;
|
||||
productReference = F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */;
|
||||
productReference = F54406E72CD4987A00C7AFD9 /* Atoll.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
|
@ -221,6 +221,7 @@
|
|||
B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */,
|
||||
146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */,
|
||||
7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */,
|
||||
7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */;
|
||||
|
|
@ -401,20 +402,26 @@
|
|||
);
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = DynamicIsland/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Atoll;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_LSUIElement = YES;
|
||||
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
|
||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD.";
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
|
||||
INFOPLIST_KEY_NSDesktopFolderUsageDescription = "Dynamic Island needs access to save screenshots to your desktop.";
|
||||
INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Dynamic Island needs access to save and read AI assistant files.";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSLocationUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather.";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather.";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "1.0.1-beta";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
MARKETING_VERSION = "1.0.2-beta";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll;
|
||||
PRODUCT_NAME = Atoll;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
|
@ -449,20 +456,26 @@
|
|||
);
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = DynamicIsland/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Atoll;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_LSUIElement = YES;
|
||||
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
|
||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD.";
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
|
||||
INFOPLIST_KEY_NSDesktopFolderUsageDescription = "Dynamic Island needs access to save screenshots to your desktop.";
|
||||
INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Dynamic Island needs access to save and read AI assistant files.";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSLocationUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather.";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather.";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "1.0.1-beta";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
MARKETING_VERSION = "1.0.2-beta";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll;
|
||||
PRODUCT_NAME = Atoll;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
|
@ -529,6 +542,14 @@
|
|||
minimumVersion = 2.6.4;
|
||||
};
|
||||
};
|
||||
7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/open-meteo/open-meteo";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.4.16;
|
||||
};
|
||||
};
|
||||
7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Lakr233/SkyLightWindow";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"originHash" : "7d8482ed9f8bfd7995906cd0663db3b8e6656cebc1c013800b81bccf13756944",
|
||||
"originHash" : "6e10ca48df0618916616b81379994d6c30d5716d19178aefa050c983082272ce",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "defaults",
|
||||
|
|
@ -55,6 +55,15 @@
|
|||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "open-meteo",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/open-meteo/open-meteo",
|
||||
"state" : {
|
||||
"revision" : "f18ccb781804c444b87e6175a4b62ecfbe0212dc",
|
||||
"version" : "1.4.16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "pow",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
|
||||
BuildableName = "DynamicIsland.app"
|
||||
BuildableName = "Atoll.app"
|
||||
BlueprintName = "DynamicIsland"
|
||||
ReferencedContainer = "container:DynamicIsland.xcodeproj">
|
||||
</BuildableReference>
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
|
||||
BuildableName = "DynamicIsland.app"
|
||||
BuildableName = "Atoll.app"
|
||||
BlueprintName = "DynamicIsland"
|
||||
ReferencedContainer = "container:DynamicIsland.xcodeproj">
|
||||
</BuildableReference>
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
|
||||
BuildableName = "DynamicIsland.app"
|
||||
BuildableName = "Atoll.app"
|
||||
BlueprintName = "DynamicIsland"
|
||||
ReferencedContainer = "container:DynamicIsland.xcodeproj">
|
||||
</BuildableReference>
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 888 B |
BIN
DynamicIsland/Assets.xcassets/AppIcon.appiconset/Atoll_1024.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
|
|
@ -1,61 +1,61 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "upscaled-7.png",
|
||||
"filename" : "Atoll_1024 (6).png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "upscaled-6 1.png",
|
||||
"filename" : "Atoll_1024 (5) 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "upscaled-6.png",
|
||||
"filename" : "Atoll_1024 (5).png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "upscaled-5.png",
|
||||
"filename" : "Atoll_1024 (4).png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "upscaled-4.png",
|
||||
"filename" : "Atoll_1024 (3).png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "upscaled-3 1.png",
|
||||
"filename" : "Atoll_1024 (2) 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "upscaled-3.png",
|
||||
"filename" : "Atoll_1024 (2).png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "upscaled-2.png",
|
||||
"filename" : "Atoll_1024 (1) 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "notch-stage-icon2 9.png",
|
||||
"filename" : "Atoll_1024 (1).png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "notch-stage-icon2 10.png",
|
||||
"filename" : "Atoll_1024.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
BIN
DynamicIsland/Assets.xcassets/logo.imageset/Atoll.png
vendored
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 754 KiB |
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ChatGPT Image Aug 13, 2025, 11_50_32 PM.png",
|
||||
"filename" : "Atoll.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
|
|
|
|||
BIN
DynamicIsland/Assets.xcassets/logo2.imageset/Atoll_1024.png
vendored
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 134 KiB |
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ChatGPT_Image_Aug_13__2025__11_50_32_PM-removebg-preview.png",
|
||||
"filename" : "Atoll_1024.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -200,14 +200,14 @@ struct ContentView: View {
|
|||
}
|
||||
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive {
|
||||
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive {
|
||||
vm.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive {
|
||||
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isMediaOutputPopoverActive {
|
||||
vm.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -215,7 +215,7 @@ struct ContentView: View {
|
|||
.onChange(of: vm.shouldRecheckHover) { _, _ in
|
||||
// Recheck hover state when popovers are closed
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !isHovering {
|
||||
if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive && !isHovering {
|
||||
vm.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -320,10 +320,9 @@ struct ContentView: View {
|
|||
}
|
||||
.sensoryFeedback(.alignment, trigger: haptics)
|
||||
.contextMenu {
|
||||
SettingsLink(label: {
|
||||
Text("Settings")
|
||||
})
|
||||
.keyboardShortcut(KeyEquivalent(","), modifiers: .command)
|
||||
Button("Settings") {
|
||||
SettingsWindowController.shared.showWindow()
|
||||
}
|
||||
// Button("Edit") { // Doesnt work....
|
||||
// let dn = DynamicNotch(content: EditPanelView())
|
||||
// dn.toggle()
|
||||
|
|
@ -404,7 +403,7 @@ struct ContentView: View {
|
|||
.frame(width: 76, alignment: .trailing)
|
||||
}
|
||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
||||
} else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) {
|
||||
} else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) {
|
||||
InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
|
||||
.transition(.opacity)
|
||||
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked {
|
||||
|
|
@ -429,7 +428,7 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
if coordinator.sneakPeek.show {
|
||||
if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] {
|
||||
if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) {
|
||||
SystemEventIndicatorModifier(eventType: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, sendEventBack: { _ in
|
||||
//
|
||||
})
|
||||
|
|
@ -631,11 +630,6 @@ struct ContentView: View {
|
|||
|
||||
// MARK: - Private Methods
|
||||
private func doOpen() {
|
||||
// Switch to timer tab if timer is active
|
||||
if timerManager.isTimerActive {
|
||||
coordinator.currentView = .timer
|
||||
}
|
||||
|
||||
withAnimation(.bouncy.speed(1.2)) {
|
||||
vm.open()
|
||||
}
|
||||
|
|
@ -754,7 +748,9 @@ struct ContentView: View {
|
|||
return vm.isBatteryPopoverActive ||
|
||||
vm.isClipboardPopoverActive ||
|
||||
vm.isColorPickerPopoverActive ||
|
||||
vm.isStatsPopoverActive
|
||||
vm.isStatsPopoverActive ||
|
||||
vm.isTimerPopoverActive ||
|
||||
vm.isMediaOutputPopoverActive
|
||||
}
|
||||
|
||||
// Helper to prevent rapid haptic feedback
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ struct DynamicNotchApp: App {
|
|||
Button("Settings") {
|
||||
SettingsWindowController.shared.showWindow()
|
||||
}
|
||||
.keyboardShortcut(KeyEquivalent(","), modifiers: .command)
|
||||
if false {
|
||||
Button("Activate License") {
|
||||
openWindow(id: "activation")
|
||||
|
|
@ -70,6 +69,14 @@ struct DynamicNotchApp: App {
|
|||
.windowStyle(.hiddenTitleBar)
|
||||
.windowResizability(.contentSize)
|
||||
}
|
||||
|
||||
var commands: some Commands {
|
||||
CommandGroup(replacing: .appSettings) {
|
||||
Button("Settings…") {
|
||||
SettingsWindowController.shared.showWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
|
@ -405,6 +412,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
self?.debouncedUpdateWindowSize()
|
||||
}.store(in: &cancellables)
|
||||
|
||||
Defaults.publisher(.enableShortcuts, options: []).sink { change in
|
||||
KeyboardShortcuts.isEnabled = change.newValue
|
||||
}.store(in: &cancellables)
|
||||
|
||||
// Observe minimalistic UI setting changes - trigger window resize
|
||||
Defaults.publisher(.enableMinimalisticUI, options: []).sink { [weak self] change in
|
||||
// Force sneak peek to standard mode when minimalistic UI is enabled
|
||||
|
|
@ -477,10 +488,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
self, selector: #selector(onScreenUnlocked(_:)),
|
||||
name: NSNotification.Name(rawValue: "com.apple.screenIsUnlocked"), object: nil)
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .toggleSneakPeek) { [weak self] in
|
||||
_ = KeyboardShortcuts.onKeyDown(for: .toggleSneakPeek) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Only execute if shortcuts are enabled
|
||||
guard Defaults[.enableShortcuts] else { return }
|
||||
|
||||
self.coordinator.toggleSneakPeek(
|
||||
|
|
@ -490,10 +499,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
)
|
||||
}
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .toggleNotchOpen) { [weak self] in
|
||||
_ = KeyboardShortcuts.onKeyDown(for: .toggleNotchOpen) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Only execute if shortcuts are enabled
|
||||
guard Defaults[.enableShortcuts] else { return }
|
||||
|
||||
let mouseLocation = NSEvent.mouseLocation
|
||||
|
|
@ -529,112 +536,72 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .startDemoTimer) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Only execute if shortcuts are enabled
|
||||
_ = KeyboardShortcuts.onKeyDown(for: .startDemoTimer) {
|
||||
guard Defaults[.enableShortcuts] else { return }
|
||||
|
||||
// Only start timer if the timer feature is enabled
|
||||
guard Defaults[.enableTimerFeature] else { return }
|
||||
|
||||
// Start a 5-minute demo timer
|
||||
TimerManager.shared.startDemoTimer(duration: 300)
|
||||
}
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .clipboardHistoryPanel) { [weak self] in
|
||||
_ = KeyboardShortcuts.onKeyDown(for: .clipboardHistoryPanel) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Only execute if shortcuts are enabled
|
||||
guard Defaults[.enableShortcuts] else { return }
|
||||
|
||||
// Only open clipboard if the feature is enabled
|
||||
guard Defaults[.enableClipboardManager] else { return }
|
||||
|
||||
// Start clipboard monitoring if not already running
|
||||
if !ClipboardManager.shared.isMonitoring {
|
||||
ClipboardManager.shared.startMonitoring()
|
||||
}
|
||||
|
||||
// Handle keyboard shortcut based on display mode
|
||||
switch Defaults[.clipboardDisplayMode] {
|
||||
case .panel:
|
||||
ClipboardPanelManager.shared.toggleClipboardPanel()
|
||||
case .popover:
|
||||
// For popover mode, first ensure notch is open, then toggle popover
|
||||
|
||||
// If notch is closed, open it first
|
||||
if self.vm.notchState == .closed {
|
||||
self.vm.open()
|
||||
// Wait a moment for the notch to open, then show popover
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
|
||||
}
|
||||
} else {
|
||||
// Notch is already open, toggle popover immediately
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .colorPickerPanel) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Only execute if shortcuts are enabled
|
||||
_ = KeyboardShortcuts.onKeyDown(for: .colorPickerPanel) {
|
||||
guard Defaults[.enableShortcuts] else { return }
|
||||
|
||||
// Only open color picker panel if the feature is enabled
|
||||
guard Defaults[.enableColorPickerFeature] else { return }
|
||||
|
||||
// Toggle color picker panel
|
||||
ColorPickerPanelManager.shared.toggleColorPickerPanel()
|
||||
}
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .screenAssistantPanel) { [weak self] in
|
||||
_ = KeyboardShortcuts.onKeyDown(for: .screenAssistantPanel) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Only execute if shortcuts are enabled
|
||||
guard Defaults[.enableShortcuts] else { return }
|
||||
|
||||
// Only open screen assistant if the feature is enabled
|
||||
guard Defaults[.enableScreenAssistant] else { return }
|
||||
|
||||
// Handle keyboard shortcut based on display mode
|
||||
switch Defaults[.screenAssistantDisplayMode] {
|
||||
case .panel:
|
||||
ScreenAssistantPanelManager.shared.toggleScreenAssistantPanel()
|
||||
case .popover:
|
||||
// For popover mode, first ensure notch is open, then toggle popover
|
||||
|
||||
// If notch is closed, open it first
|
||||
if self.vm.notchState == .closed {
|
||||
self.vm.open()
|
||||
// Wait a moment for the notch to open, then show popover
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil)
|
||||
}
|
||||
} else {
|
||||
// Notch is already open, toggle popover immediately
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .statsPanel) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
KeyboardShortcuts.isEnabled = Defaults[.enableShortcuts]
|
||||
|
||||
// Only execute if shortcuts are enabled
|
||||
_ = KeyboardShortcuts.onKeyDown(for: .statsPanel) {
|
||||
guard Defaults[.enableShortcuts] else { return }
|
||||
|
||||
// Only open stats panel if both stats feature and panel are enabled
|
||||
guard Defaults[.enableStatsFeature] && Defaults[.showStatsPanel] else { return }
|
||||
|
||||
// Start stats monitoring if not already running
|
||||
if !StatsManager.shared.isMonitoring {
|
||||
StatsManager.shared.startMonitoring()
|
||||
}
|
||||
|
||||
// Toggle stats panel
|
||||
StatsPanelManager.shared.toggleStatsPanel()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,6 @@
|
|||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD.</string>
|
||||
<key>NSLocationUsageDescription</key>
|
||||
<string>Dynamic Island uses your location to display accurate lock screen weather.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Dynamic Island uses your location to display accurate lock screen weather.</string>
|
||||
<key>NSScreenCaptureUsageDescription</key>
|
||||
<string>Dynamic Island needs screen recording permission to capture screenshots for the AI assistant feature.</string>
|
||||
<key>SUEnableDownloaderService</key>
|
||||
|
|
|
|||
|
|
@ -9,16 +9,31 @@
|
|||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
enum ColorCodingMode {
|
||||
case volume
|
||||
case battery
|
||||
|
||||
func intensity(for value: CGFloat) -> CGFloat {
|
||||
let clamped = min(max(value, 0), 1)
|
||||
switch self {
|
||||
case .volume:
|
||||
return clamped
|
||||
case .battery:
|
||||
return 1 - clamped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ColorCodedProgressBar: View {
|
||||
let value: CGFloat // 0.0 to 1.0
|
||||
let reversed: Bool // If true: green at low, red at high (for volume)
|
||||
let mode: ColorCodingMode
|
||||
let width: CGFloat
|
||||
let height: CGFloat
|
||||
let smoothGradient: Bool // If true: smooth gradient, if false: discrete colors
|
||||
|
||||
init(value: CGFloat, reversed: Bool = false, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) {
|
||||
init(value: CGFloat, mode: ColorCodingMode = .battery, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) {
|
||||
self.value = min(max(value, 0), 1) // Clamp between 0 and 1
|
||||
self.reversed = reversed
|
||||
self.mode = mode
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.smoothGradient = smoothGradient
|
||||
|
|
@ -34,100 +49,19 @@ struct ColorCodedProgressBar: View {
|
|||
|
||||
// Filled track with color gradient or solid color
|
||||
RoundedRectangle(cornerRadius: height / 2)
|
||||
.fill(smoothGradient ?
|
||||
AnyShapeStyle(LinearGradient(
|
||||
gradient: gradientForValue,
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)) :
|
||||
AnyShapeStyle(colorForPercentage(value, reversed: reversed))
|
||||
)
|
||||
.fill(fillStyle)
|
||||
.frame(width: width * value, height: height)
|
||||
}
|
||||
}
|
||||
.frame(width: width, height: height)
|
||||
}
|
||||
|
||||
/// Generates a gradient based on the current value
|
||||
private var gradientForValue: Gradient {
|
||||
let color = colorForPercentage(value, reversed: reversed)
|
||||
|
||||
// Create a gradient that transitions smoothly
|
||||
let startColor = colorForPercentage(max(value - 0.1, 0), reversed: reversed)
|
||||
let endColor = color
|
||||
|
||||
return Gradient(colors: [startColor, endColor])
|
||||
}
|
||||
|
||||
/// Calculates discrete color for a given percentage (no smooth transitions)
|
||||
/// - Parameters:
|
||||
/// - percentage: Value from 0.0 to 1.0
|
||||
/// - reversed: If true, reverses the color scale (green at low, red at high)
|
||||
/// - Returns: Discrete color (red, yellow, or green only)
|
||||
private func discreteColorForPercentage(_ percentage: CGFloat, reversed: Bool) -> Color {
|
||||
let normalized = reversed ? (1.0 - percentage) : percentage
|
||||
|
||||
switch normalized {
|
||||
case 0.0..<0.33:
|
||||
// Red zone (critical/low)
|
||||
return Color(red: 1.0, green: 0.23, blue: 0.19)
|
||||
case 0.33..<0.67:
|
||||
// Yellow zone (medium)
|
||||
return Color(red: 1.0, green: 0.8, blue: 0.0)
|
||||
default:
|
||||
// Green zone (healthy/high)
|
||||
return Color(red: 0.2, green: 0.78, blue: 0.35)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates color for a given percentage with smooth transitions
|
||||
/// - Parameters:
|
||||
/// - percentage: Value from 0.0 to 1.0
|
||||
/// - reversed: If true, reverses the color scale (green at low, red at high)
|
||||
/// - Returns: Color with smooth gradient transitions
|
||||
private func colorForPercentage(_ percentage: CGFloat, reversed: Bool) -> Color {
|
||||
let normalized = reversed ? (1.0 - percentage) : percentage
|
||||
|
||||
switch normalized {
|
||||
case 0.0..<0.2:
|
||||
// Pure Red zone (critical/low)
|
||||
return Color(red: 1.0, green: 0.23, blue: 0.19)
|
||||
|
||||
case 0.2..<0.35:
|
||||
// Red → Orange transition
|
||||
let t = (normalized - 0.2) / 0.15
|
||||
let red: CGFloat = 1.0
|
||||
let green: CGFloat = 0.23 + (0.37 * t) // 0.23 → 0.6
|
||||
let blue: CGFloat = 0.19
|
||||
return Color(red: red, green: green, blue: blue)
|
||||
|
||||
case 0.35..<0.5:
|
||||
// Orange → Yellow transition
|
||||
let t = (normalized - 0.35) / 0.15
|
||||
let red: CGFloat = 1.0
|
||||
let green: CGFloat = 0.6 + (0.4 * t) // 0.6 → 1.0
|
||||
let blue: CGFloat = 0.19
|
||||
return Color(red: red, green: green, blue: blue)
|
||||
|
||||
case 0.5..<0.65:
|
||||
// Yellow → Yellow-Green transition
|
||||
let t = (normalized - 0.5) / 0.15
|
||||
let red: CGFloat = 1.0 - (0.4 * t) // 1.0 → 0.6
|
||||
let green: CGFloat = 1.0
|
||||
let blue: CGFloat = 0.19 + (0.4 * t) // 0.19 → 0.59
|
||||
return Color(red: red, green: green, blue: blue)
|
||||
|
||||
case 0.65..<0.8:
|
||||
// Yellow-Green → Green transition
|
||||
let t = (normalized - 0.65) / 0.15
|
||||
let red: CGFloat = 0.6 - (0.4 * t) // 0.6 → 0.2
|
||||
let green: CGFloat = 1.0 - (0.22 * t) // 1.0 → 0.78
|
||||
let blue: CGFloat = 0.59 - (0.24 * t) // 0.59 → 0.35
|
||||
return Color(red: red, green: green, blue: blue)
|
||||
|
||||
default:
|
||||
// Pure Green zone (healthy/high)
|
||||
return Color(red: 0.2, green: 0.78, blue: 0.35)
|
||||
private var fillStyle: AnyShapeStyle {
|
||||
let intensity = mode.intensity(for: value)
|
||||
if smoothGradient {
|
||||
return AnyShapeStyle(ColorCodedPalette.gradient(for: intensity))
|
||||
} else {
|
||||
return AnyShapeStyle(ColorCodedPalette.discreteColor(for: intensity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -137,12 +71,92 @@ struct ColorCodedProgressBar: View {
|
|||
extension ColorCodedProgressBar {
|
||||
/// Creates a battery-style progress bar (green at high, red at low)
|
||||
static func battery(value: CGFloat, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) -> some View {
|
||||
ColorCodedProgressBar(value: value, reversed: false, width: width, height: height, smoothGradient: smoothGradient)
|
||||
ColorCodedProgressBar(value: value, mode: .battery, width: width, height: height, smoothGradient: smoothGradient)
|
||||
}
|
||||
|
||||
/// Creates a volume-style progress bar (red at high, green at low)
|
||||
static func volume(value: CGFloat, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) -> some View {
|
||||
ColorCodedProgressBar(value: value, reversed: true, width: width, height: height, smoothGradient: smoothGradient)
|
||||
ColorCodedProgressBar(value: value, mode: .volume, width: width, height: height, smoothGradient: smoothGradient)
|
||||
}
|
||||
|
||||
/// Shared helper for other progress bar implementations that need the same palette.
|
||||
static func shapeStyle(for value: CGFloat, mode: ColorCodingMode, smoothGradient: Bool) -> AnyShapeStyle {
|
||||
let intensity = mode.intensity(for: min(max(value, 0), 1))
|
||||
if smoothGradient {
|
||||
return AnyShapeStyle(ColorCodedPalette.gradient(for: intensity))
|
||||
}
|
||||
return AnyShapeStyle(ColorCodedPalette.discreteColor(for: intensity))
|
||||
}
|
||||
|
||||
/// Returns the palette color that matches the configured thresholds. Useful for non-linear indicators.
|
||||
static func paletteColor(for value: CGFloat, mode: ColorCodingMode, smoothGradient: Bool) -> Color {
|
||||
let normalized = min(max(value, 0), 1)
|
||||
let intensity = mode.intensity(for: normalized)
|
||||
return ColorCodedPalette.color(for: intensity, smooth: smoothGradient)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Palette Helpers
|
||||
|
||||
private enum ColorCodedPalette {
|
||||
private static let greenComponents: (CGFloat, CGFloat, CGFloat) = (0.2, 0.78, 0.35)
|
||||
private static let yellowComponents: (CGFloat, CGFloat, CGFloat) = (0.93, 0.75, 0.2)
|
||||
private static let redComponents: (CGFloat, CGFloat, CGFloat) = (0.95, 0.36, 0.3)
|
||||
|
||||
static func color(for intensity: CGFloat, smooth: Bool) -> Color {
|
||||
if smooth {
|
||||
return smoothColor(for: intensity)
|
||||
}
|
||||
return discreteColor(for: intensity)
|
||||
}
|
||||
|
||||
static func discreteColor(for intensity: CGFloat) -> Color {
|
||||
let clamped = min(max(intensity, 0), 1)
|
||||
switch clamped {
|
||||
case ..<0.6:
|
||||
return color(from: greenComponents)
|
||||
case ..<0.85:
|
||||
return color(from: yellowComponents)
|
||||
default:
|
||||
return color(from: redComponents)
|
||||
}
|
||||
}
|
||||
|
||||
static func gradient(for intensity: CGFloat) -> LinearGradient {
|
||||
let endColor = smoothColor(for: intensity)
|
||||
let startColor = smoothColor(for: max(intensity - 0.15, 0))
|
||||
return LinearGradient(colors: [startColor, endColor], startPoint: .leading, endPoint: .trailing)
|
||||
}
|
||||
|
||||
private static func smoothColor(for intensity: CGFloat) -> Color {
|
||||
let clamped = min(max(intensity, 0), 1)
|
||||
switch clamped {
|
||||
case ..<0.6:
|
||||
// Stay green within the comfort zone
|
||||
return color(from: greenComponents)
|
||||
case ..<0.85:
|
||||
let t = (clamped - 0.6) / 0.25
|
||||
return blend(from: greenComponents, to: yellowComponents, fraction: t)
|
||||
default:
|
||||
let t = (clamped - 0.85) / 0.15
|
||||
return blend(from: yellowComponents, to: redComponents, fraction: t)
|
||||
}
|
||||
}
|
||||
|
||||
private static func blend(from start: (CGFloat, CGFloat, CGFloat), to end: (CGFloat, CGFloat, CGFloat), fraction: CGFloat) -> Color {
|
||||
let t = min(max(fraction, 0), 1)
|
||||
let red = lerp(start.0, end.0, t)
|
||||
let green = lerp(start.1, end.1, t)
|
||||
let blue = lerp(start.2, end.2, t)
|
||||
return color(from: (red, green, blue))
|
||||
}
|
||||
|
||||
private static func lerp(_ a: CGFloat, _ b: CGFloat, _ t: CGFloat) -> CGFloat {
|
||||
a + (b - a) * t
|
||||
}
|
||||
|
||||
private static func color(from components: (CGFloat, CGFloat, CGFloat)) -> Color {
|
||||
Color(red: Double(components.0), green: Double(components.1), blue: Double(components.2))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,12 +20,52 @@ struct InlineHUD: View {
|
|||
@Default(.useColorCodedVolumeDisplay) var useColorCodedVolumeDisplay
|
||||
@Default(.useSmoothColorGradient) var useSmoothColorGradient
|
||||
@Default(.progressBarStyle) var progressBarStyle
|
||||
@Default(.showProgressPercentages) var showProgressPercentages
|
||||
@Default(.useCircularBluetoothBatteryIndicator) var useCircularBluetoothBatteryIndicator
|
||||
@Default(.showBluetoothBatteryPercentageText) var showBluetoothBatteryPercentageText
|
||||
@Default(.showBluetoothDeviceNameMarquee) var showBluetoothDeviceNameMarquee
|
||||
@ObservedObject var bluetoothManager = BluetoothAudioManager.shared
|
||||
|
||||
@State private var displayName: String = ""
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
let useCircularIndicator = useCircularBluetoothBatteryIndicator
|
||||
let hasBatteryLevel = value > 0
|
||||
|
||||
let baseInfoWidth: CGFloat = {
|
||||
if type == .bluetoothAudio {
|
||||
return showBluetoothDeviceNameMarquee ? 140 : 88
|
||||
}
|
||||
return 100
|
||||
}()
|
||||
let infoWidth: CGFloat = {
|
||||
var width = baseInfoWidth + gestureProgress / 2
|
||||
if !hoverAnimation { width -= 8 }
|
||||
let minimum: CGFloat = type == .bluetoothAudio ? (showBluetoothDeviceNameMarquee ? 120 : 82) : 88
|
||||
return max(width, minimum)
|
||||
}()
|
||||
let baseTrailingWidth: CGFloat = {
|
||||
if type == .bluetoothAudio {
|
||||
if !hasBatteryLevel {
|
||||
return showBluetoothDeviceNameMarquee ? 118 : 88
|
||||
}
|
||||
|
||||
if useCircularIndicator {
|
||||
return showBluetoothBatteryPercentageText ? 120 : 96
|
||||
} else {
|
||||
return showBluetoothBatteryPercentageText ? 136 : 108
|
||||
}
|
||||
}
|
||||
return 100
|
||||
}()
|
||||
let trailingWidth: CGFloat = {
|
||||
var width = baseTrailingWidth + gestureProgress / 2
|
||||
if !hoverAnimation { width -= 8 }
|
||||
let minimum: CGFloat = type == .bluetoothAudio ? (showBluetoothBatteryPercentageText ? 110 : 92) : 90
|
||||
return max(width, minimum)
|
||||
}()
|
||||
|
||||
return HStack {
|
||||
HStack(spacing: 5) {
|
||||
Group {
|
||||
switch (type) {
|
||||
|
|
@ -77,22 +117,15 @@ struct InlineHUD: View {
|
|||
|
||||
// Use marquee text for device names to handle long names
|
||||
if type == .bluetoothAudio {
|
||||
if showBluetoothDeviceNameMarquee {
|
||||
MarqueeText(
|
||||
$displayName,
|
||||
font: .system(size: 13, weight: .medium),
|
||||
nsFont: .body,
|
||||
textColor: .white,
|
||||
minDuration: 0.5,
|
||||
frameWidth: 85 - (hoverAnimation ? 0 : 12) + gestureProgress / 2
|
||||
minDuration: 0.2,
|
||||
frameWidth: infoWidth
|
||||
)
|
||||
.onAppear {
|
||||
displayName = Type2Name(type)
|
||||
}
|
||||
.onChange(of: type) { _, _ in
|
||||
displayName = Type2Name(type)
|
||||
}
|
||||
.onChange(of: bluetoothManager.lastConnectedDevice?.name) { _, _ in
|
||||
displayName = Type2Name(type)
|
||||
}
|
||||
} else {
|
||||
Text(Type2Name(type))
|
||||
|
|
@ -103,7 +136,7 @@ struct InlineHUD: View {
|
|||
.contentTransition(.numericText())
|
||||
}
|
||||
}
|
||||
.frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.notchSize.height - (hoverAnimation ? 0 : 12), alignment: .leading)
|
||||
.frame(width: infoWidth, height: vm.notchSize.height - (hoverAnimation ? 0 : 12), alignment: .leading)
|
||||
|
||||
Rectangle()
|
||||
.fill(.black)
|
||||
|
|
@ -127,45 +160,40 @@ struct InlineHUD: View {
|
|||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.contentTransition(.interpolate)
|
||||
} else if (type == .bluetoothAudio) {
|
||||
// Bluetooth device battery display
|
||||
HStack(spacing: 4) {
|
||||
if value > 0 {
|
||||
// Show color-coded battery bar if battery info available
|
||||
// Color-coding disabled for segmented or hierarchical mode
|
||||
if useColorCodedBatteryDisplay && progressBarStyle == .gradient {
|
||||
ColorCodedProgressBar.battery(value: value, width: 60, height: 4, smoothGradient: useSmoothColorGradient)
|
||||
if hasBatteryLevel {
|
||||
HStack(spacing: useCircularIndicator ? 8 : 6) {
|
||||
if useCircularIndicator {
|
||||
CircularBatteryIndicator(
|
||||
value: value,
|
||||
useColorCoding: useColorCodedBatteryDisplay && progressBarStyle != .segmented,
|
||||
smoothGradient: useSmoothColorGradient
|
||||
)
|
||||
.allowsHitTesting(false)
|
||||
} else {
|
||||
DraggableProgressBar(value: .constant(value))
|
||||
.frame(width: 60)
|
||||
LinearBatteryIndicator(
|
||||
value: value,
|
||||
useColorCoding: useColorCodedBatteryDisplay && progressBarStyle != .segmented,
|
||||
smoothGradient: useSmoothColorGradient
|
||||
)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
if showBluetoothBatteryPercentageText {
|
||||
Text("\(Int(value * 100))%")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
// No battery info available
|
||||
Text("Connected")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.gray)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
} else {
|
||||
// Volume and brightness displays
|
||||
HStack {
|
||||
// Color-coding only available in gradient mode, not hierarchical or segmented
|
||||
if type == .volume && useColorCodedVolumeDisplay && progressBarStyle == .gradient {
|
||||
// Color-coded volume (reversed: red at high, green at low)
|
||||
ColorCodedProgressBar.volume(value: value, width: 70, height: 4, smoothGradient: useSmoothColorGradient)
|
||||
} else {
|
||||
// Default draggable progress bar
|
||||
DraggableProgressBar(value: $value)
|
||||
}
|
||||
|
||||
if (type == .volume && value.isZero) {
|
||||
Group {
|
||||
if type == .volume {
|
||||
Group {
|
||||
if value.isZero {
|
||||
Text("muted")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
|
|
@ -173,14 +201,104 @@ struct InlineHUD: View {
|
|||
.lineLimit(1)
|
||||
.allowsTightening(true)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.contentTransition(.numericText())
|
||||
} else {
|
||||
HStack(spacing: 6) {
|
||||
DraggableProgressBar(value: $value, colorMode: .volume)
|
||||
PercentageLabel(value: value, isVisible: showProgressPercentages)
|
||||
}
|
||||
.transition(.opacity.combined(with: .scale))
|
||||
}
|
||||
}
|
||||
.animation(.smooth(duration: 0.2), value: value.isZero)
|
||||
} else {
|
||||
HStack(spacing: 6) {
|
||||
DraggableProgressBar(value: $value)
|
||||
PercentageLabel(value: value, isVisible: showProgressPercentages)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 4)
|
||||
.frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center)
|
||||
.frame(width: trailingWidth, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center)
|
||||
}
|
||||
.frame(height: vm.closedNotchSize.height + (hoverAnimation ? 8 : 0), alignment: .center)
|
||||
.onAppear {
|
||||
displayName = Type2Name(type)
|
||||
}
|
||||
.onChange(of: type) { _, _ in
|
||||
displayName = Type2Name(type)
|
||||
}
|
||||
.onChange(of: bluetoothManager.lastConnectedDevice?.name) { _, _ in
|
||||
displayName = Type2Name(type)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CircularBatteryIndicator: View {
|
||||
let value: CGFloat
|
||||
let useColorCoding: Bool
|
||||
let smoothGradient: Bool
|
||||
|
||||
private var clampedValue: CGFloat {
|
||||
min(max(value, 0), 1)
|
||||
}
|
||||
|
||||
private var indicatorColor: Color {
|
||||
if useColorCoding {
|
||||
return ColorCodedProgressBar.paletteColor(for: clampedValue, mode: .battery, smoothGradient: smoothGradient)
|
||||
}
|
||||
return .white
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.18), lineWidth: 2.6)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: max(clampedValue, 0.015))
|
||||
.rotation(.degrees(-90))
|
||||
.stroke(indicatorColor, style: StrokeStyle(lineWidth: 2.8, lineCap: .round))
|
||||
}
|
||||
.frame(width: 22, height: 22)
|
||||
.animation(.smooth(duration: 0.18), value: clampedValue)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LinearBatteryIndicator: View {
|
||||
let value: CGFloat
|
||||
let useColorCoding: Bool
|
||||
let smoothGradient: Bool
|
||||
|
||||
private let trackWidth: CGFloat = 54
|
||||
private let trackHeight: CGFloat = 6
|
||||
|
||||
private var clampedValue: CGFloat {
|
||||
min(max(value, 0), 1)
|
||||
}
|
||||
|
||||
private var fillColor: Color {
|
||||
if useColorCoding {
|
||||
return ColorCodedProgressBar.paletteColor(for: clampedValue, mode: .battery, smoothGradient: smoothGradient)
|
||||
}
|
||||
return .white
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.18))
|
||||
.frame(width: trackWidth, height: trackHeight)
|
||||
|
||||
Capsule()
|
||||
.fill(fillColor)
|
||||
.frame(width: trackWidth * clampedValue, height: trackHeight)
|
||||
}
|
||||
.frame(width: trackWidth, height: trackHeight)
|
||||
.animation(.smooth(duration: 0.18), value: clampedValue)
|
||||
}
|
||||
}
|
||||
|
||||
func SpeakerSymbol(_ value: CGFloat) -> String {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ struct SystemEventIndicatorModifier: View {
|
|||
@Binding var icon: String
|
||||
let showSlider: Bool = false
|
||||
var sendEventBack: (CGFloat) -> Void
|
||||
@Default(.showProgressPercentages) private var showProgressPercentages
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
|
|
@ -58,13 +59,21 @@ struct SystemEventIndicatorModifier: View {
|
|||
default:
|
||||
EmptyView()
|
||||
}
|
||||
if (eventType != .mic) {
|
||||
DraggableProgressBar(value: $value)
|
||||
} else {
|
||||
Text("Mic \(value > 0 ? "unmuted" : "muted")")
|
||||
switch eventType {
|
||||
case .mic:
|
||||
Text(value > 0 ? "Mic unmuted" : "Mic muted")
|
||||
.foregroundStyle(.gray)
|
||||
.lineLimit(1)
|
||||
.allowsTightening(true)
|
||||
.contentTransition(.numericText())
|
||||
case .volume:
|
||||
VolumeProgressSection(value: $value, showPercentages: showProgressPercentages)
|
||||
case .brightness:
|
||||
ProgressSection(value: $value, showPercentages: showProgressPercentages)
|
||||
case .backlight:
|
||||
ProgressSection(value: $value, showPercentages: showProgressPercentages)
|
||||
default:
|
||||
ProgressSection(value: $value, showPercentages: showProgressPercentages)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
|
@ -88,18 +97,106 @@ struct SystemEventIndicatorModifier: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct ProgressSection: View {
|
||||
@Binding var value: CGFloat
|
||||
var showPercentages: Bool
|
||||
var colorMode: ProgressColorMode? = nil
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
DraggableProgressBar(value: $value, colorMode: colorMode)
|
||||
PercentageLabel(value: value, isVisible: showPercentages)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct VolumeProgressSection: View {
|
||||
@Binding var value: CGFloat
|
||||
var showPercentages: Bool
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if value.isZero {
|
||||
Text("muted")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.gray)
|
||||
.contentTransition(.numericText())
|
||||
} else {
|
||||
ProgressSection(value: $value, showPercentages: showPercentages, colorMode: .volume)
|
||||
.transition(.opacity.combined(with: .scale))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.animation(.smooth(duration: 0.2), value: value.isZero)
|
||||
}
|
||||
}
|
||||
|
||||
struct PercentageLabel: View {
|
||||
let value: CGFloat
|
||||
let isVisible: Bool
|
||||
@Default(.progressBarStyle) private var progressBarStyle
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if shouldShow {
|
||||
Text(formattedValue)
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.contentTransition(.numericText())
|
||||
.frame(width: 28, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldShow: Bool {
|
||||
isVisible && progressBarStyle != .segmented
|
||||
}
|
||||
|
||||
private var formattedValue: String {
|
||||
let raw = max(0, min(100, Int(round(value * 100))))
|
||||
return String(format: "%3d", raw)
|
||||
}
|
||||
}
|
||||
|
||||
enum ProgressColorMode {
|
||||
case volume
|
||||
case battery
|
||||
}
|
||||
|
||||
private extension ProgressColorMode {
|
||||
var colorCodingMode: ColorCodingMode {
|
||||
switch self {
|
||||
case .volume:
|
||||
return .volume
|
||||
case .battery:
|
||||
return .battery
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DraggableProgressBar: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@Binding var value: CGFloat
|
||||
var colorMode: ProgressColorMode? = nil
|
||||
|
||||
@State private var isDragging = false
|
||||
@State private var dragOffset: CGFloat = 0
|
||||
@Default(.progressBarStyle) private var progressBarStyle
|
||||
@Default(.useColorCodedVolumeDisplay) private var useColorCodedVolumeDisplay
|
||||
@Default(.useColorCodedBatteryDisplay) private var useColorCodedBatteryDisplay
|
||||
@Default(.useSmoothColorGradient) private var useSmoothColorGradient
|
||||
@Default(.systemEventIndicatorUseAccent) private var useAccentColor
|
||||
@Default(.systemEventIndicatorShadow) private var useShadow
|
||||
@Default(.inlineHUD) private var inlineHUD
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
GeometryReader { geo in
|
||||
Group {
|
||||
if Defaults[.progressBarStyle] == .segmented {
|
||||
if progressBarStyle == .segmented {
|
||||
// Segmented progress bar - completely different layout
|
||||
SegmentedProgressContent(value: value, geometry: geo)
|
||||
} else {
|
||||
|
|
@ -107,22 +204,7 @@ struct DraggableProgressBar: View {
|
|||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(.tertiary)
|
||||
Group {
|
||||
switch Defaults[.progressBarStyle] {
|
||||
case .gradient:
|
||||
Capsule()
|
||||
.fill(LinearGradient(colors: Defaults[.systemEventIndicatorUseAccent] ? [Defaults[.accentColor], Defaults[.accentColor].ensureMinimumBrightness(factor: 0.2)] : [.white, .white.opacity(0.2)], startPoint: .trailing, endPoint: .leading))
|
||||
.frame(width: max(0, min(geo.size.width * value, geo.size.width)))
|
||||
.shadow(color: Defaults[.systemEventIndicatorShadow] ? Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white : .clear, radius: 8, x: 3)
|
||||
case .hierarchical:
|
||||
Capsule()
|
||||
.fill(Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor] : .white)
|
||||
.frame(width: max(0, min(geo.size.width * value, geo.size.width)))
|
||||
.shadow(color: Defaults[.systemEventIndicatorShadow] ? Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white : .clear, radius: 8, x: 3)
|
||||
case .segmented:
|
||||
EmptyView() // This case won't be reached due to the outer if condition
|
||||
}
|
||||
}
|
||||
progressFill(width: geo.size.width)
|
||||
.opacity(value.isZero ? 0 : 1)
|
||||
}
|
||||
}
|
||||
|
|
@ -142,7 +224,7 @@ struct DraggableProgressBar: View {
|
|||
}
|
||||
)
|
||||
}
|
||||
.frame(height: Defaults[.inlineHUD] ? isDragging ? 8 : 5 : isDragging ? 9 : 6)
|
||||
.frame(height: inlineHUD ? (isDragging ? 8 : 5) : (isDragging ? 9 : 6))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +234,59 @@ struct DraggableProgressBar: View {
|
|||
|
||||
value = max(0, min(newValue, 1))
|
||||
}
|
||||
|
||||
private func progressFill(width: CGFloat) -> some View {
|
||||
let clampedWidth = max(0, min(width * value, width))
|
||||
let accentColor = Defaults[.accentColor]
|
||||
let shadowColor: Color = {
|
||||
guard useShadow else { return .clear }
|
||||
if useAccentColor {
|
||||
return accentColor.ensureMinimumBrightness(factor: 0.7)
|
||||
}
|
||||
return .white
|
||||
}()
|
||||
|
||||
return Group {
|
||||
if shouldUseColorCoding, let mode = colorMode?.colorCodingMode {
|
||||
Capsule()
|
||||
.fill(ColorCodedProgressBar.shapeStyle(for: value, mode: mode, smoothGradient: useSmoothColorGradient))
|
||||
.frame(width: clampedWidth)
|
||||
.shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3)
|
||||
} else {
|
||||
switch progressBarStyle {
|
||||
case .gradient:
|
||||
Capsule()
|
||||
.fill(LinearGradient(
|
||||
colors: useAccentColor
|
||||
? [accentColor, accentColor.ensureMinimumBrightness(factor: 0.2)]
|
||||
: [.white, .white.opacity(0.2)],
|
||||
startPoint: .trailing,
|
||||
endPoint: .leading
|
||||
))
|
||||
.frame(width: clampedWidth)
|
||||
.shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3)
|
||||
case .hierarchical:
|
||||
Capsule()
|
||||
.fill(useAccentColor ? accentColor : .white)
|
||||
.frame(width: clampedWidth)
|
||||
.shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3)
|
||||
case .segmented:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldUseColorCoding: Bool {
|
||||
guard let colorMode else { return false }
|
||||
guard progressBarStyle != .segmented else { return false }
|
||||
switch colorMode {
|
||||
case .volume:
|
||||
return useColorCodedVolumeDisplay
|
||||
case .battery:
|
||||
return useColorCodedBatteryDisplay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SegmentedProgressContent: View {
|
||||
|
|
@ -165,7 +300,7 @@ struct SegmentedProgressContent: View {
|
|||
var body: some View {
|
||||
let spacing: CGFloat = 1.5
|
||||
let computed = (geometry.size.width - CGFloat(segmentCount - 1) * spacing) / CGFloat(segmentCount)
|
||||
let barWidth = max(3.0, min(6.0, computed))
|
||||
let barWidth = max(3.0, computed)
|
||||
let activeCount = Int(round(value * CGFloat(segmentCount)))
|
||||
|
||||
HStack(spacing: spacing) {
|
||||
|
|
|
|||
|
|
@ -13,15 +13,19 @@ struct LockScreenMusicPanel: View {
|
|||
static let expandedSize = CGSize(width: 720, height: 340)
|
||||
|
||||
@ObservedObject var musicManager = MusicManager.shared
|
||||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||||
@State private var sliderValue: Double = 0
|
||||
@State private var dragging: Bool = false
|
||||
@State private var isActive = true
|
||||
@State private var isExpanded = false
|
||||
@State private var isVolumeSliderVisible = false
|
||||
@State private var collapseWorkItem: DispatchWorkItem?
|
||||
@Default(.lockScreenGlassStyle) var lockScreenGlassStyle
|
||||
@Default(.lockScreenShowAppIcon) var showAppIcon
|
||||
@Default(.lockScreenPanelShowsBorder) var showPanelBorder
|
||||
@Default(.lockScreenPanelUsesBlur) var enableBlur
|
||||
@Default(.showMediaOutputControl) var showMediaOutputControl
|
||||
|
||||
private let collapsedPanelCornerRadius: CGFloat = 28
|
||||
private let expandedPanelCornerRadius: CGFloat = 52
|
||||
|
|
@ -29,9 +33,12 @@ struct LockScreenMusicPanel: View {
|
|||
private let expandedAlbumArtCornerRadius: CGFloat = 60
|
||||
private let expandedContentSpacing: CGFloat = 40
|
||||
private let collapseTimeout: TimeInterval = 5
|
||||
private let collapsedSliderExtraHeight: CGFloat = 72
|
||||
private let expandedSliderExtraHeight: CGFloat = 88
|
||||
|
||||
private var currentSize: CGSize {
|
||||
isExpanded ? Self.expandedSize : Self.collapsedSize
|
||||
let base = isExpanded ? Self.expandedSize : Self.collapsedSize
|
||||
return CGSize(width: base.width, height: base.height + sliderExtraHeight)
|
||||
}
|
||||
|
||||
private var panelCornerRadius: CGFloat {
|
||||
|
|
@ -58,18 +65,39 @@ struct LockScreenMusicPanel: View {
|
|||
panelCore
|
||||
.frame(width: currentSize.width, height: currentSize.height)
|
||||
.animation(.spring(response: 0.48, dampingFraction: 0.82, blendDuration: 0.18), value: isExpanded)
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.85, blendDuration: 0.18), value: shouldShowVolumeSlider)
|
||||
.onAppear {
|
||||
sliderValue = musicManager.elapsedTime
|
||||
isActive = true
|
||||
logPanelAppearance()
|
||||
LockScreenPanelManager.shared.updatePanelSize(expanded: false, animated: false)
|
||||
LockScreenPanelManager.shared.updatePanelSize(
|
||||
expanded: isExpanded,
|
||||
additionalHeight: sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider),
|
||||
animated: false
|
||||
)
|
||||
routeManager.refreshDevices()
|
||||
}
|
||||
.onDisappear {
|
||||
isActive = false
|
||||
cancelCollapseTimer()
|
||||
isVolumeSliderVisible = false
|
||||
}
|
||||
.onChange(of: isExpanded) { _, expanded in
|
||||
LockScreenPanelManager.shared.updatePanelSize(expanded: expanded)
|
||||
LockScreenPanelManager.shared.updatePanelSize(
|
||||
expanded: expanded,
|
||||
additionalHeight: sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider)
|
||||
)
|
||||
}
|
||||
.onChange(of: showMediaOutputControl) { _, enabled in
|
||||
if !enabled {
|
||||
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
|
||||
isVolumeSliderVisible = false
|
||||
}
|
||||
}
|
||||
LockScreenPanelManager.shared.updatePanelSize(
|
||||
expanded: isExpanded,
|
||||
additionalHeight: sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -332,8 +360,22 @@ struct LockScreenMusicPanel: View {
|
|||
private func playbackControls(alignment: Alignment) -> some View {
|
||||
let spacing: CGFloat = isExpanded ? 24 : 20
|
||||
|
||||
return HStack(spacing: spacing) {
|
||||
// Always show shuffle on lock screen
|
||||
return VStack(spacing: shouldShowVolumeSlider ? 14 : 10) {
|
||||
controlsRow(alignment: alignment, spacing: spacing)
|
||||
|
||||
if shouldShowVolumeSlider {
|
||||
volumeSlider
|
||||
.frame(maxWidth: .infinity, alignment: alignment)
|
||||
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: alignment)
|
||||
.padding(.top, isExpanded ? 6 : 2)
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider)
|
||||
}
|
||||
|
||||
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
|
||||
HStack(spacing: spacing) {
|
||||
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
|
||||
musicManager.toggleShuffle()
|
||||
}
|
||||
|
|
@ -348,13 +390,15 @@ struct LockScreenMusicPanel: View {
|
|||
musicManager.nextTrack()
|
||||
}
|
||||
|
||||
// Always show repeat on lock screen
|
||||
if showMediaOutputControl {
|
||||
mediaOutputControlButton
|
||||
} else {
|
||||
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
|
||||
musicManager.toggleRepeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: alignment)
|
||||
.padding(.top, isExpanded ? 6 : 2)
|
||||
}
|
||||
|
||||
private var playPauseButton: some View {
|
||||
|
|
@ -391,6 +435,21 @@ struct LockScreenMusicPanel: View {
|
|||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
private var mediaOutputControlButton: some View {
|
||||
let frameSize: CGFloat = isExpanded ? 56 : 32
|
||||
let iconSize: CGFloat = isExpanded ? 26 : 18
|
||||
|
||||
return Button(action: toggleVolumeSlider) {
|
||||
Image(systemName: mediaOutputIcon)
|
||||
.font(.system(size: iconSize, weight: .medium))
|
||||
.foregroundColor(shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8))
|
||||
.frame(width: frameSize, height: frameSize)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.accessibilityLabel("Media output")
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
private var repeatIcon: String {
|
||||
switch musicManager.repeatMode {
|
||||
case .off: return "repeat"
|
||||
|
|
@ -399,6 +458,87 @@ struct LockScreenMusicPanel: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var mediaOutputIcon: String {
|
||||
routeManager.activeDevice?.iconName ?? "speaker.wave.2"
|
||||
}
|
||||
|
||||
private var volumeSlider: some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: volumeIconName)
|
||||
.font(.system(size: isExpanded ? 16 : 14, weight: .semibold))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(volumeModel.level) },
|
||||
set: { newValue in
|
||||
registerInteraction()
|
||||
volumeModel.setVolume(Float(newValue))
|
||||
}
|
||||
),
|
||||
in: 0 ... 1
|
||||
)
|
||||
.tint(sliderColor)
|
||||
|
||||
Text(volumePercentage)
|
||||
.font(.system(size: isExpanded ? 12 : 11, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous)
|
||||
.fill(Color.white.opacity(0.08))
|
||||
)
|
||||
}
|
||||
|
||||
private var shouldShowVolumeSlider: Bool {
|
||||
showMediaOutputControl && isVolumeSliderVisible
|
||||
}
|
||||
|
||||
private var sliderExtraHeight: CGFloat {
|
||||
sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
|
||||
}
|
||||
|
||||
private var volumeIconName: String {
|
||||
if volumeModel.isMuted || volumeModel.level <= 0.001 {
|
||||
return "speaker.slash.fill"
|
||||
} else if volumeModel.level < 0.33 {
|
||||
return "speaker.wave.1.fill"
|
||||
} else if volumeModel.level < 0.66 {
|
||||
return "speaker.wave.2.fill"
|
||||
}
|
||||
return "speaker.wave.3.fill"
|
||||
}
|
||||
|
||||
private var volumePercentage: String {
|
||||
"\(Int(round(volumeModel.level * 100)))%"
|
||||
}
|
||||
|
||||
private func toggleVolumeSlider() {
|
||||
registerInteraction()
|
||||
let newState = !isVolumeSliderVisible
|
||||
if newState {
|
||||
routeManager.refreshDevices()
|
||||
}
|
||||
|
||||
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
|
||||
isVolumeSliderVisible = newState
|
||||
}
|
||||
|
||||
let targetHeight = sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
|
||||
LockScreenPanelManager.shared.updatePanelSize(
|
||||
expanded: isExpanded,
|
||||
additionalHeight: targetHeight
|
||||
)
|
||||
}
|
||||
|
||||
private func sliderHeight(forExpanded expanded: Bool, visible: Bool) -> CGFloat {
|
||||
guard visible else { return 0 }
|
||||
return expanded ? expandedSliderExtraHeight : collapsedSliderExtraHeight
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var panelBackground: some View {
|
||||
if enableBlur {
|
||||
|
|
|
|||
|
|
@ -1,47 +1,593 @@
|
|||
import SwiftUI
|
||||
|
||||
|
||||
struct LockScreenWeatherWidget: View {
|
||||
let snapshot: LockScreenWeatherSnapshot
|
||||
|
||||
private let inlinePrimaryFont = Font.system(size: 22, weight: .semibold, design: .rounded)
|
||||
private let inlineSecondaryFont = Font.system(size: 13, weight: .medium, design: .rounded)
|
||||
private let secondaryLabelColor = Color.white.opacity(0.7)
|
||||
|
||||
private var isInline: Bool { snapshot.widgetStyle == .inline }
|
||||
private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top }
|
||||
private var stackSpacing: CGFloat { isInline ? 14 : 22 }
|
||||
private var gaugeDiameter: CGFloat { 64 }
|
||||
private var topPadding: CGFloat { isInline ? 6 : 22 }
|
||||
private var bottomPadding: CGFloat { isInline ? 6 : 10 }
|
||||
|
||||
private var monochromeGaugeTint: Color {
|
||||
Color.white.opacity(0.9)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: snapshot.symbolName)
|
||||
.font(.system(size: 26, weight: .medium))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text(snapshot.temperatureText)
|
||||
.font(.system(size: 24, weight: .semibold, design: .rounded))
|
||||
.kerning(-0.4)
|
||||
if let locationName = snapshot.locationName, !locationName.isEmpty {
|
||||
Text("•")
|
||||
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
||||
Text(locationName)
|
||||
.font(.system(size: 20, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(Color.white.opacity(0.65))
|
||||
HStack(alignment: stackAlignment, spacing: stackSpacing) {
|
||||
if let charging = snapshot.charging {
|
||||
chargingSegment(for: charging)
|
||||
}
|
||||
|
||||
if !isInline, let battery = snapshot.battery {
|
||||
batterySegment(for: battery)
|
||||
}
|
||||
|
||||
if let bluetooth = snapshot.bluetooth {
|
||||
bluetoothSegment(for: bluetooth)
|
||||
}
|
||||
|
||||
if let airQuality = snapshot.airQuality {
|
||||
airQualitySegment(for: airQuality)
|
||||
}
|
||||
|
||||
weatherSegment
|
||||
|
||||
if shouldShowLocation {
|
||||
locationSegment
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.foregroundStyle(Color.white.opacity(0.65))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.top, topPadding)
|
||||
.padding(.bottom, bottomPadding)
|
||||
.background(Color.clear)
|
||||
.shadow(color: .black.opacity(0.35), radius: 8, x: 0, y: 3)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var weatherSegment: some View {
|
||||
switch snapshot.widgetStyle {
|
||||
case .inline:
|
||||
inlineWeatherSegment
|
||||
case .circular:
|
||||
circularWeatherSegment
|
||||
}
|
||||
}
|
||||
|
||||
private var inlineWeatherSegment: some View {
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
Image(systemName: snapshot.symbolName)
|
||||
.font(.system(size: 26, weight: .medium))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text(snapshot.temperatureText)
|
||||
.font(inlinePrimaryFont)
|
||||
.kerning(-0.3)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.9)
|
||||
.layoutPriority(2)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var circularWeatherSegment: some View {
|
||||
if let info = snapshot.temperatureInfo {
|
||||
temperatureGauge(for: info)
|
||||
} else {
|
||||
inlineWeatherSegment
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func chargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
|
||||
switch snapshot.widgetStyle {
|
||||
case .inline:
|
||||
inlineChargingSegment(for: info)
|
||||
case .circular:
|
||||
circularChargingSegment(for: info)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func batterySegment(for info: LockScreenWeatherSnapshot.BatteryInfo) -> some View {
|
||||
switch snapshot.widgetStyle {
|
||||
case .inline:
|
||||
EmptyView()
|
||||
case .circular:
|
||||
circularBatterySegment(for: info)
|
||||
}
|
||||
}
|
||||
|
||||
private func inlineChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
if let iconName = chargingIconName(for: info) {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
Text(inlineChargingLabel(for: info))
|
||||
.font(inlinePrimaryFont)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
.layoutPriority(1)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func circularChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
|
||||
if let rawLevel = info.batteryLevel {
|
||||
let level = clampedBatteryLevel(rawLevel)
|
||||
|
||||
VStack(spacing: 6) {
|
||||
Gauge(value: Double(level), in: 0...100) {
|
||||
EmptyView()
|
||||
} currentValueLabel: {
|
||||
chargingGlyph(for: info)
|
||||
} minimumValueLabel: {
|
||||
Text("0")
|
||||
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(secondaryLabelColor)
|
||||
} maximumValueLabel: {
|
||||
Text("100")
|
||||
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(secondaryLabelColor)
|
||||
}
|
||||
.gaugeStyle(.accessoryCircularCapacity)
|
||||
.tint(batteryTint(for: level))
|
||||
.frame(width: gaugeDiameter, height: gaugeDiameter)
|
||||
|
||||
Text(chargingDetailLabel(for: info))
|
||||
.font(inlineSecondaryFont)
|
||||
.foregroundStyle(secondaryLabelColor)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.layoutPriority(1)
|
||||
} else {
|
||||
inlineChargingSegment(for: info)
|
||||
}
|
||||
}
|
||||
|
||||
private func circularBatterySegment(for info: LockScreenWeatherSnapshot.BatteryInfo) -> some View {
|
||||
let level = clampedBatteryLevel(info.batteryLevel)
|
||||
|
||||
return VStack(spacing: 6) {
|
||||
Gauge(value: Double(level), in: 0...100) {
|
||||
EmptyView()
|
||||
} currentValueLabel: {
|
||||
Image(systemName: batteryIconName(for: level))
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(Color.white)
|
||||
} minimumValueLabel: {
|
||||
EmptyView()
|
||||
} maximumValueLabel: {
|
||||
EmptyView()
|
||||
}
|
||||
.gaugeStyle(.accessoryCircularCapacity)
|
||||
.tint(batteryTint(for: level))
|
||||
.frame(width: gaugeDiameter, height: gaugeDiameter)
|
||||
|
||||
Text("\(level)%")
|
||||
.font(inlineSecondaryFont)
|
||||
.foregroundStyle(secondaryLabelColor)
|
||||
}
|
||||
.layoutPriority(1)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func bluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
|
||||
switch snapshot.widgetStyle {
|
||||
case .inline:
|
||||
inlineBluetoothSegment(for: info)
|
||||
case .circular:
|
||||
circularBluetoothSegment(for: info)
|
||||
}
|
||||
}
|
||||
|
||||
private func inlineBluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: info.iconName)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text(bluetoothPercentageText(for: info.batteryLevel))
|
||||
.font(inlinePrimaryFont)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
.layoutPriority(1)
|
||||
}
|
||||
|
||||
private func circularBluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
|
||||
let clamped = clampedBatteryLevel(info.batteryLevel)
|
||||
|
||||
return VStack(spacing: 6) {
|
||||
Gauge(value: Double(clamped), in: 0...100) {
|
||||
EmptyView()
|
||||
} currentValueLabel: {
|
||||
Image(systemName: info.iconName)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(Color.white)
|
||||
} minimumValueLabel: {
|
||||
EmptyView()
|
||||
} maximumValueLabel: {
|
||||
EmptyView()
|
||||
}
|
||||
.gaugeStyle(.accessoryCircularCapacity)
|
||||
.tint(bluetoothTint(for: clamped))
|
||||
.frame(width: gaugeDiameter, height: gaugeDiameter)
|
||||
|
||||
Text(bluetoothPercentageText(for: info.batteryLevel))
|
||||
.font(inlineSecondaryFont)
|
||||
.foregroundStyle(secondaryLabelColor)
|
||||
}
|
||||
.layoutPriority(1)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func airQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View {
|
||||
switch snapshot.widgetStyle {
|
||||
case .inline:
|
||||
inlineAirQualitySegment(for: info)
|
||||
case .circular:
|
||||
circularAirQualitySegment(for: info)
|
||||
}
|
||||
}
|
||||
|
||||
private func inlineAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: "wind")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
inlineComposite(primary: "AQI \(info.index)", secondary: info.category.displayName)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
.layoutPriority(1)
|
||||
}
|
||||
|
||||
private func circularAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View {
|
||||
VStack(spacing: 6) {
|
||||
Gauge(value: Double(info.index), in: 0...500) {
|
||||
EmptyView()
|
||||
} currentValueLabel: {
|
||||
Text("\(info.index)")
|
||||
.font(.system(size: 20, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(Color.white)
|
||||
}
|
||||
.gaugeStyle(.accessoryCircular)
|
||||
.tint(aqiTint(for: info.category))
|
||||
.frame(width: gaugeDiameter, height: gaugeDiameter)
|
||||
|
||||
Text("AQI · \(info.category.displayName)")
|
||||
.font(inlineSecondaryFont)
|
||||
.foregroundStyle(secondaryLabelColor)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.layoutPriority(1)
|
||||
}
|
||||
|
||||
private func temperatureGauge(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> some View {
|
||||
let range = temperatureRange(for: info)
|
||||
|
||||
return VStack(spacing: 6) {
|
||||
Gauge(value: info.current, in: range) {
|
||||
EmptyView()
|
||||
} currentValueLabel: {
|
||||
temperatureCenterLabel(for: info)
|
||||
}
|
||||
.gaugeStyle(.accessoryCircular)
|
||||
.tint(temperatureTint(for: info))
|
||||
.frame(width: gaugeDiameter, height: gaugeDiameter)
|
||||
|
||||
HStack {
|
||||
Text(minimumTemperatureLabel(for: info))
|
||||
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(secondaryLabelColor)
|
||||
Spacer()
|
||||
Text(maximumTemperatureLabel(for: info))
|
||||
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(secondaryLabelColor)
|
||||
}
|
||||
.frame(width: gaugeDiameter)
|
||||
}
|
||||
.layoutPriority(1)
|
||||
}
|
||||
|
||||
private var locationSegment: some View {
|
||||
Text(snapshot.locationName ?? "")
|
||||
.font(isInline ? inlinePrimaryFont : inlineSecondaryFont)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.minimumScaleFactor(0.75)
|
||||
.layoutPriority(0.7)
|
||||
}
|
||||
|
||||
private var shouldShowLocation: Bool {
|
||||
snapshot.showsLocation && (snapshot.locationName?.isEmpty == false)
|
||||
}
|
||||
|
||||
private func chargingIconName(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? {
|
||||
let icon = info.iconName
|
||||
return icon.isEmpty ? nil : icon
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func chargingGlyph(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
|
||||
if let iconName = chargingIconName(for: info) {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(Color.white)
|
||||
} else {
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(Color.white)
|
||||
}
|
||||
}
|
||||
|
||||
private func inlineChargingLabel(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String {
|
||||
if let time = formattedChargingTime(for: info) {
|
||||
return time
|
||||
}
|
||||
return chargingStatusFallback(for: info)
|
||||
}
|
||||
|
||||
private func chargingDetailLabel(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String {
|
||||
inlineChargingLabel(for: info)
|
||||
}
|
||||
|
||||
private func formattedChargingTime(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? {
|
||||
guard let minutes = info.minutesRemaining, minutes > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let hours = minutes / 60
|
||||
let remainingMinutes = minutes % 60
|
||||
|
||||
if hours > 0 {
|
||||
return "\(hours)h \(remainingMinutes)m"
|
||||
}
|
||||
return "\(remainingMinutes)m"
|
||||
}
|
||||
|
||||
private func chargingStatusFallback(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String {
|
||||
if info.isPluggedIn && !info.isCharging {
|
||||
return NSLocalizedString("Fully charged", comment: "Charging fallback label when already charged")
|
||||
}
|
||||
return NSLocalizedString("Charging", comment: "Charging fallback label when no estimate is available")
|
||||
}
|
||||
|
||||
private func bluetoothPercentageText(for level: Int) -> String {
|
||||
"\(clampedBatteryLevel(level))%"
|
||||
}
|
||||
|
||||
private func minimumTemperatureLabel(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> String {
|
||||
if let minimum = info.displayMinimum {
|
||||
return "\(minimum)°"
|
||||
}
|
||||
return "—"
|
||||
}
|
||||
|
||||
private func maximumTemperatureLabel(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> String {
|
||||
if let maximum = info.displayMaximum {
|
||||
return "\(maximum)°"
|
||||
}
|
||||
return "—"
|
||||
}
|
||||
|
||||
private func temperatureCenterLabel(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> some View {
|
||||
return HStack(alignment: .top, spacing: 2) {
|
||||
Text("\(info.displayCurrent)°")
|
||||
.font(.system(size: 20, weight: .semibold, design: .rounded))
|
||||
}
|
||||
.foregroundStyle(Color.white)
|
||||
}
|
||||
|
||||
private func temperatureRange(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> ClosedRange<Double> {
|
||||
let minimumCandidate = info.minimum ?? info.current
|
||||
let maximumCandidate = info.maximum ?? info.current
|
||||
var lowerBound = min(minimumCandidate, info.current)
|
||||
var upperBound = max(maximumCandidate, info.current)
|
||||
|
||||
if lowerBound == upperBound {
|
||||
lowerBound -= 1
|
||||
upperBound += 1
|
||||
}
|
||||
|
||||
return lowerBound...upperBound
|
||||
}
|
||||
|
||||
private func clampedBatteryLevel(_ level: Int) -> Int {
|
||||
min(max(level, 0), 100)
|
||||
}
|
||||
|
||||
private func inlineComposite(primary: String, secondary: String?) -> Text {
|
||||
var text = Text(primary).font(inlinePrimaryFont)
|
||||
if let secondary, !secondary.isEmpty {
|
||||
text = text + Text(" \(secondary)")
|
||||
.font(inlineSecondaryFont)
|
||||
.foregroundStyle(secondaryLabelColor)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private func batteryTint(for level: Int) -> Color {
|
||||
guard snapshot.usesGaugeTint else { return monochromeGaugeTint }
|
||||
let clamped = clampedBatteryLevel(level)
|
||||
switch clamped {
|
||||
case ..<20:
|
||||
return Color(.systemRed)
|
||||
case 20..<50:
|
||||
return Color(.systemOrange)
|
||||
default:
|
||||
return Color(.systemGreen)
|
||||
}
|
||||
}
|
||||
|
||||
private func bluetoothTint(for level: Int) -> Color {
|
||||
guard snapshot.usesGaugeTint else { return monochromeGaugeTint }
|
||||
let clamped = clampedBatteryLevel(level)
|
||||
switch clamped {
|
||||
case ..<20:
|
||||
return Color(.systemRed)
|
||||
case 20..<50:
|
||||
return Color(.systemOrange)
|
||||
default:
|
||||
return Color(.systemGreen)
|
||||
}
|
||||
}
|
||||
|
||||
private func aqiTint(for category: LockScreenWeatherSnapshot.AirQualityInfo.Category) -> Color {
|
||||
guard snapshot.usesGaugeTint else { return monochromeGaugeTint }
|
||||
switch category {
|
||||
case .good:
|
||||
return Color(red: 0.20, green: 0.79, blue: 0.39)
|
||||
case .moderate:
|
||||
return Color(red: 0.97, green: 0.82, blue: 0.30)
|
||||
case .unhealthyForSensitive:
|
||||
return Color(red: 0.98, green: 0.57, blue: 0.24)
|
||||
case .unhealthy:
|
||||
return Color(red: 0.91, green: 0.29, blue: 0.25)
|
||||
case .veryUnhealthy:
|
||||
return Color(red: 0.65, green: 0.32, blue: 0.86)
|
||||
case .hazardous:
|
||||
return Color(red: 0.50, green: 0.13, blue: 0.28)
|
||||
case .unknown:
|
||||
return Color(red: 0.63, green: 0.66, blue: 0.74)
|
||||
}
|
||||
}
|
||||
|
||||
private func temperatureTint(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> Color {
|
||||
guard snapshot.usesGaugeTint else { return monochromeGaugeTint }
|
||||
let value = info.current
|
||||
switch value {
|
||||
case ..<0:
|
||||
return Color(red: 0.29, green: 0.63, blue: 1.00)
|
||||
case 0..<15:
|
||||
return Color(red: 0.20, green: 0.79, blue: 0.93)
|
||||
case 15..<25:
|
||||
return Color(red: 0.20, green: 0.79, blue: 0.39)
|
||||
case 25..<32:
|
||||
return Color(red: 0.97, green: 0.58, blue: 0.29)
|
||||
default:
|
||||
return Color(red: 0.91, green: 0.29, blue: 0.25)
|
||||
}
|
||||
}
|
||||
|
||||
private var accessibilityLabel: String {
|
||||
if let locationName = snapshot.locationName, !locationName.isEmpty {
|
||||
return String(
|
||||
var components: [String] = []
|
||||
|
||||
if snapshot.showsLocation, let locationName = snapshot.locationName, !locationName.isEmpty {
|
||||
components.append(
|
||||
String(
|
||||
format: NSLocalizedString("Weather: %@ %@ in %@", comment: "Weather description, temperature, and location"),
|
||||
snapshot.description,
|
||||
snapshot.temperatureText,
|
||||
locationName
|
||||
)
|
||||
}
|
||||
|
||||
return String(
|
||||
)
|
||||
} else {
|
||||
components.append(
|
||||
String(
|
||||
format: NSLocalizedString("Weather: %@ %@", comment: "Weather description and temperature"),
|
||||
snapshot.description,
|
||||
snapshot.temperatureText
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if let charging = snapshot.charging {
|
||||
components.append(accessibilityChargingText(for: charging))
|
||||
}
|
||||
|
||||
if let bluetooth = snapshot.bluetooth {
|
||||
components.append(accessibilityBluetoothText(for: bluetooth))
|
||||
}
|
||||
|
||||
if let airQuality = snapshot.airQuality {
|
||||
components.append(accessibilityAirQualityText(for: airQuality))
|
||||
}
|
||||
|
||||
if let battery = snapshot.battery, !isInline {
|
||||
components.append(accessibilityBatteryText(for: battery))
|
||||
}
|
||||
|
||||
return components.joined(separator: ". ")
|
||||
}
|
||||
|
||||
private func accessibilityChargingText(for charging: LockScreenWeatherSnapshot.ChargingInfo) -> String {
|
||||
if let minutes = charging.minutesRemaining, minutes > 0 {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = [.hour, .minute]
|
||||
formatter.unitsStyle = .full
|
||||
let duration = formatter.string(from: TimeInterval(minutes * 60)) ?? "\(minutes) minutes"
|
||||
return String(
|
||||
format: NSLocalizedString("Battery charging, %@ remaining", comment: "Charging time remaining"),
|
||||
duration
|
||||
)
|
||||
}
|
||||
|
||||
if charging.isPluggedIn && !charging.isCharging {
|
||||
return NSLocalizedString("Battery fully charged", comment: "Battery is full")
|
||||
}
|
||||
|
||||
if snapshot.showsChargingPercentage, let level = charging.batteryLevel {
|
||||
return String(
|
||||
format: NSLocalizedString("Battery at %d percent", comment: "Battery percentage"),
|
||||
level
|
||||
)
|
||||
}
|
||||
|
||||
return NSLocalizedString("Battery charging", comment: "Battery charging without estimate")
|
||||
}
|
||||
|
||||
private func accessibilityBluetoothText(for bluetooth: LockScreenWeatherSnapshot.BluetoothInfo) -> String {
|
||||
String(
|
||||
format: NSLocalizedString("Bluetooth device %@ at %d percent", comment: "Bluetooth device battery"),
|
||||
bluetooth.deviceName,
|
||||
bluetooth.batteryLevel
|
||||
)
|
||||
}
|
||||
|
||||
private func accessibilityAirQualityText(for airQuality: LockScreenWeatherSnapshot.AirQualityInfo) -> String {
|
||||
String(
|
||||
format: NSLocalizedString("Air quality index %d, %@", comment: "Air quality accessibility label"),
|
||||
airQuality.index,
|
||||
airQuality.category.displayName
|
||||
)
|
||||
}
|
||||
|
||||
private func accessibilityBatteryText(for battery: LockScreenWeatherSnapshot.BatteryInfo) -> String {
|
||||
String(
|
||||
format: NSLocalizedString("Mac battery at %d percent", comment: "Mac battery gauge accessibility label"),
|
||||
clampedBatteryLevel(battery.batteryLevel)
|
||||
)
|
||||
}
|
||||
|
||||
private func batteryIconName(for level: Int) -> String {
|
||||
let clamped = clampedBatteryLevel(level)
|
||||
switch clamped {
|
||||
case ..<10:
|
||||
return "battery.0percent"
|
||||
case 10..<40:
|
||||
return "battery.25percent"
|
||||
case 40..<70:
|
||||
return "battery.50percent"
|
||||
case 70..<90:
|
||||
return "battery.75percent"
|
||||
default:
|
||||
return "battery.100percent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,16 +23,21 @@ struct MinimalisticMusicView: View {
|
|||
Rectangle()
|
||||
.fill(.black)
|
||||
.overlay(
|
||||
GeometryReader { geo in
|
||||
HStack(alignment: .center) {
|
||||
if !musicManager.songTitle.isEmpty {
|
||||
MarqueeText(
|
||||
.constant(musicManager.songTitle),
|
||||
$musicManager.songTitle,
|
||||
font: .system(size: 12, weight: .semibold),
|
||||
nsFont: .subheadline,
|
||||
textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray,
|
||||
minDuration: 0.4,
|
||||
frameWidth: 100
|
||||
frameWidth: max(0, geo.size.width - 8)
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height, alignment: .center)
|
||||
}
|
||||
)
|
||||
.frame(width: vm.closedNotchSize.width)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,8 +15,11 @@ struct DynamicIslandHeader: View {
|
|||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@ObservedObject var clipboardManager = ClipboardManager.shared
|
||||
@ObservedObject var tvm = TrayDrop.shared
|
||||
@ObservedObject var timerManager = TimerManager.shared
|
||||
@State private var showClipboardPopover = false
|
||||
@State private var showColorPickerPopover = false
|
||||
@State private var showTimerPopover = false
|
||||
@Default(.enableTimerFeature) var enableTimerFeature
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
|
|
@ -141,6 +144,36 @@ struct DynamicIslandHeader: View {
|
|||
}
|
||||
}
|
||||
|
||||
if Defaults[.enableTimerFeature] {
|
||||
Button(action: {
|
||||
withAnimation(.smooth) {
|
||||
showTimerPopover.toggle()
|
||||
}
|
||||
}) {
|
||||
Capsule()
|
||||
.fill(.black)
|
||||
.frame(width: 30, height: 30)
|
||||
.overlay {
|
||||
Image(systemName: "timer")
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.imageScale(.medium)
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.popover(isPresented: $showTimerPopover, arrowEdge: .bottom) {
|
||||
TimerPopover()
|
||||
}
|
||||
.onChange(of: showTimerPopover) { isActive in
|
||||
vm.isTimerPopoverActive = isActive
|
||||
if !isActive {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
vm.shouldRecheckHover.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if Defaults[.settingsIconInNotch] {
|
||||
Button(action: {
|
||||
SettingsWindowController.shared.showWindow()
|
||||
|
|
@ -204,6 +237,12 @@ struct DynamicIslandHeader: View {
|
|||
showClipboardPopover.toggle()
|
||||
}
|
||||
}
|
||||
.onChange(of: enableTimerFeature) { _, newValue in
|
||||
if !newValue {
|
||||
showTimerPopover = false
|
||||
vm.isTimerPopoverActive = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,35 +11,45 @@ import Defaults
|
|||
struct MinimalisticMusicPlayerView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
let albumArtNamespace: Namespace.ID
|
||||
@Default(.showMediaOutputControl) var showMediaOutputControl
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header area with album art (matching DynamicIslandHeader height of 24pt)
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
GeometryReader { headerGeo in
|
||||
let albumArtWidth: CGFloat = 50
|
||||
let spacing: CGFloat = 10
|
||||
let visualizerWidth: CGFloat = useMusicVisualizer ? 24 : 0
|
||||
let textWidth = max(0, headerGeo.size.width - albumArtWidth - spacing - (useMusicVisualizer ? (visualizerWidth + spacing) : 0))
|
||||
HStack(alignment: .center, spacing: spacing) {
|
||||
MinimalisticAlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace)
|
||||
.frame(width: 50, height: 50)
|
||||
.frame(width: albumArtWidth, height: albumArtWidth)
|
||||
|
||||
// Song info aligned to bottom of album art
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(MusicManager.shared.songTitle)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(MusicManager.shared.artistName)
|
||||
.font(.system(size: 10, weight: .regular))
|
||||
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: MusicManager.shared.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
|
||||
.lineLimit(1)
|
||||
if !musicManager.songTitle.isEmpty {
|
||||
MarqueeText(
|
||||
$musicManager.songTitle,
|
||||
font: .system(size: 12, weight: .semibold),
|
||||
nsFont: .subheadline,
|
||||
textColor: .white,
|
||||
frameWidth: textWidth
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Text(musicManager.artistName)
|
||||
.font(.system(size: 10, weight: .regular))
|
||||
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(width: textWidth, alignment: .leading)
|
||||
|
||||
// Visualizer aligned to bottom
|
||||
if useMusicVisualizer {
|
||||
visualizer
|
||||
.frame(width: visualizerWidth)
|
||||
}
|
||||
}
|
||||
.frame(height: 50) // Fixed height to accommodate album art
|
||||
}
|
||||
.frame(height: 50)
|
||||
|
||||
// Compact progress bar
|
||||
progressBar
|
||||
|
|
@ -182,11 +192,15 @@ struct MinimalisticMusicPlayerView: View {
|
|||
}
|
||||
|
||||
if Defaults[.showShuffleAndRepeat] {
|
||||
if showMediaOutputControl {
|
||||
MinimalisticMediaOutputButton()
|
||||
} else {
|
||||
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
|
||||
Task { await musicManager.toggleRepeat() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
|
@ -216,6 +230,60 @@ struct MinimalisticMusicPlayerView: View {
|
|||
action: action
|
||||
)
|
||||
}
|
||||
private struct MinimalisticMediaOutputButton: View {
|
||||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||||
@EnvironmentObject private var vm: DynamicIslandViewModel
|
||||
@State private var isPopoverPresented = false
|
||||
@State private var isHoveringPopover = false
|
||||
|
||||
var body: some View {
|
||||
MinimalisticSquircircleButton(
|
||||
icon: routeManager.activeDevice?.iconName ?? "speaker.wave.2",
|
||||
fontSize: 18,
|
||||
fontWeight: .medium,
|
||||
frameSize: CGSize(width: 40, height: 40),
|
||||
cornerRadius: 16,
|
||||
foregroundColor: .white.opacity(0.85)
|
||||
) {
|
||||
isPopoverPresented.toggle()
|
||||
if isPopoverPresented {
|
||||
routeManager.refreshDevices()
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Media output")
|
||||
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
|
||||
MediaOutputSelectorPopover(
|
||||
routeManager: routeManager,
|
||||
volumeModel: volumeModel,
|
||||
onHoverChanged: { hovering in
|
||||
isHoveringPopover = hovering
|
||||
updateActivity()
|
||||
}
|
||||
) {
|
||||
isPopoverPresented = false
|
||||
isHoveringPopover = false
|
||||
updateActivity()
|
||||
}
|
||||
}
|
||||
.onChange(of: isPopoverPresented) { _, presented in
|
||||
if !presented {
|
||||
isHoveringPopover = false
|
||||
}
|
||||
updateActivity()
|
||||
}
|
||||
.onAppear {
|
||||
routeManager.refreshDevices()
|
||||
}
|
||||
.onDisappear {
|
||||
vm.isMediaOutputPopoverActive = false
|
||||
}
|
||||
}
|
||||
|
||||
private func updateActivity() {
|
||||
vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
|
||||
}
|
||||
}
|
||||
|
||||
private var repeatIcon: String {
|
||||
switch musicManager.repeatMode {
|
||||
|
|
|
|||
|
|
@ -18,10 +18,14 @@ struct MusicPlayerView: View {
|
|||
let showShuffleAndRepeat: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace).padding(.all, 5)
|
||||
MusicControlsView(showShuffleAndRepeat: showShuffleAndRepeat).drawingGroup().compositingGroup()
|
||||
MusicControlsView(showShuffleAndRepeat: showShuffleAndRepeat)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.drawingGroup()
|
||||
.compositingGroup()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,6 +120,7 @@ struct MusicControlsView: View {
|
|||
@State private var dragging: Bool = false
|
||||
@State private var lastDragged: Date = .distantPast
|
||||
let showShuffleAndRepeat: Bool
|
||||
@Default(.showMediaOutputControl) private var showMediaOutputControl
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
|
|
@ -123,6 +128,7 @@ struct MusicControlsView: View {
|
|||
playbackControls
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var songInfoAndSlider: some View {
|
||||
|
|
@ -134,6 +140,7 @@ struct MusicControlsView: View {
|
|||
}
|
||||
.padding(.top, 10)
|
||||
.padding(.leading, 5)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func songInfo(width: CGFloat) -> some View {
|
||||
|
|
@ -192,11 +199,15 @@ struct MusicControlsView: View {
|
|||
MusicManager.shared.nextTrack()
|
||||
}
|
||||
if showShuffleAndRepeat {
|
||||
if showMediaOutputControl {
|
||||
MediaOutputPickerButton()
|
||||
} else {
|
||||
HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) {
|
||||
MusicManager.shared.toggleRepeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
|
|
@ -247,6 +258,7 @@ struct NotchHomeView: View {
|
|||
} else {
|
||||
// Normal mode: Show full music player with optional calendar and webcam
|
||||
MusicPlayerView(albumArtNamespace: albumArtNamespace, showShuffleAndRepeat: Defaults[.showShuffleAndRepeat])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if Defaults[.showCalendar] {
|
||||
CalendarView()
|
||||
|
|
@ -382,6 +394,239 @@ struct CustomSlider: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct MediaOutputPickerButton: View {
|
||||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||||
@State private var isPopoverPresented = false
|
||||
@State private var isHoveringPopover = false
|
||||
@EnvironmentObject private var vm: DynamicIslandViewModel
|
||||
|
||||
var body: some View {
|
||||
HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) {
|
||||
isPopoverPresented.toggle()
|
||||
if isPopoverPresented {
|
||||
routeManager.refreshDevices()
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Media output")
|
||||
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
|
||||
MediaOutputSelectorPopover(
|
||||
routeManager: routeManager,
|
||||
volumeModel: volumeModel,
|
||||
onHoverChanged: { hovering in
|
||||
isHoveringPopover = hovering
|
||||
updatePopoverActivity()
|
||||
}
|
||||
) {
|
||||
isPopoverPresented = false
|
||||
isHoveringPopover = false
|
||||
updatePopoverActivity()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
routeManager.refreshDevices()
|
||||
}
|
||||
.onChange(of: isPopoverPresented) { _, presented in
|
||||
if !presented {
|
||||
isHoveringPopover = false
|
||||
}
|
||||
updatePopoverActivity()
|
||||
}
|
||||
.onDisappear {
|
||||
vm.isMediaOutputPopoverActive = false
|
||||
}
|
||||
}
|
||||
|
||||
private var buttonIcon: String {
|
||||
routeManager.activeDevice?.iconName ?? "speaker.wave.2"
|
||||
}
|
||||
|
||||
private func updatePopoverActivity() {
|
||||
vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaOutputSelectorPopover: View {
|
||||
@ObservedObject var routeManager: AudioRouteManager
|
||||
@ObservedObject var volumeModel: MediaOutputVolumeViewModel
|
||||
var onHoverChanged: (Bool) -> Void
|
||||
var dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
volumeSection
|
||||
Divider()
|
||||
devicesSection
|
||||
}
|
||||
.frame(width: 240)
|
||||
.padding(16)
|
||||
.onHover { hovering in
|
||||
onHoverChanged(hovering)
|
||||
}
|
||||
.onDisappear {
|
||||
onHoverChanged(false)
|
||||
}
|
||||
}
|
||||
|
||||
private var volumeSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
volumeModel.toggleMute()
|
||||
} label: {
|
||||
Image(systemName: volumeIconName)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.secondary.opacity(0.18))
|
||||
)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(volumeModel.level) },
|
||||
set: { newValue in
|
||||
volumeModel.setVolume(Float(newValue))
|
||||
}
|
||||
),
|
||||
in: 0 ... 1
|
||||
)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Output volume")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(volumePercentage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var devicesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Output devices")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if routeManager.devices.isEmpty {
|
||||
Text("No audio outputs available")
|
||||
.font(.callout)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 6) {
|
||||
ForEach(routeManager.devices) { device in
|
||||
Button {
|
||||
routeManager.select(device: device)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: device.iconName)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
Text(device.name)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if device.id == routeManager.activeDeviceID {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(device.id == routeManager.activeDeviceID ? Color.primary.opacity(0.12) : .clear)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 180)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var volumeIconName: String {
|
||||
if volumeModel.isMuted || volumeModel.level <= 0.001 {
|
||||
return "speaker.slash.fill"
|
||||
} else if volumeModel.level < 0.33 {
|
||||
return "speaker.wave.1.fill"
|
||||
} else if volumeModel.level < 0.66 {
|
||||
return "speaker.wave.2.fill"
|
||||
}
|
||||
return "speaker.wave.3.fill"
|
||||
}
|
||||
|
||||
private var volumePercentage: String {
|
||||
"\(Int(round(volumeModel.level * 100)))%"
|
||||
}
|
||||
}
|
||||
|
||||
final class MediaOutputVolumeViewModel: ObservableObject {
|
||||
@Published var level: Float
|
||||
@Published var isMuted: Bool
|
||||
|
||||
private let controller: SystemVolumeController
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
init(controller: SystemVolumeController = .shared) {
|
||||
self.controller = controller
|
||||
controller.start()
|
||||
level = controller.currentVolume
|
||||
isMuted = controller.isMuted
|
||||
|
||||
NotificationCenter.default.publisher(for: .systemVolumeDidChange)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] notification in
|
||||
guard let self,
|
||||
let value = notification.userInfo?["value"] as? Float,
|
||||
let muted = notification.userInfo?["muted"] as? Bool else { return }
|
||||
self.level = value
|
||||
self.isMuted = muted
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .systemAudioRouteDidChange)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.syncFromController()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func setVolume(_ value: Float) {
|
||||
level = value
|
||||
if value > 0 {
|
||||
isMuted = false
|
||||
}
|
||||
controller.setVolume(value)
|
||||
}
|
||||
|
||||
func toggleMute() {
|
||||
isMuted.toggle()
|
||||
controller.toggleMute()
|
||||
}
|
||||
|
||||
private func syncFromController() {
|
||||
level = controller.currentVolume
|
||||
isMuted = controller.isMuted
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NotchHomeView(
|
||||
albumArtNamespace: Namespace().wrappedValue
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ struct NotchTimerView: View {
|
|||
// Timer icon in center
|
||||
Image(systemName: "timer")
|
||||
.font(.system(size: 32, weight: .medium))
|
||||
.foregroundStyle(timerManager.currentColor)
|
||||
.foregroundStyle(openIconColor)
|
||||
.scaleEffect(timerManager.isRunning && timerManager.isTimerActive ? 1.1 : 1.0)
|
||||
.animation(
|
||||
timerManager.isRunning && timerManager.isTimerActive ?
|
||||
|
|
@ -287,6 +287,13 @@ struct NotchTimerView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private extension NotchTimerView {
|
||||
var openIconColor: Color {
|
||||
guard timerManager.isTimerActive && timerManager.isRunning else { return .white.opacity(0.9) }
|
||||
return timerManager.currentColor
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NotchTimerView()
|
||||
.environmentObject(DynamicIslandViewModel())
|
||||
|
|
|
|||
|
|
@ -4,13 +4,12 @@
|
|||
//
|
||||
// Created by Richard Kunkli on 07/08/2024.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Defaults
|
||||
import EventKit
|
||||
import KeyboardShortcuts
|
||||
import LaunchAtLogin
|
||||
import LottieUI
|
||||
import AVFoundation
|
||||
import Sparkle
|
||||
import SwiftUI
|
||||
import SwiftUIIntrospect
|
||||
|
|
@ -178,6 +177,17 @@ struct GeneralSettings: View {
|
|||
@Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon
|
||||
@Default(.lockScreenPanelShowsBorder) var lockScreenPanelShowsBorder
|
||||
@Default(.lockScreenPanelUsesBlur) var lockScreenPanelUsesBlur
|
||||
@Default(.enableLockScreenWeatherWidget) var enableLockScreenWeatherWidget
|
||||
@Default(.lockScreenWeatherShowsLocation) var lockScreenWeatherShowsLocation
|
||||
@Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging
|
||||
@Default(.lockScreenWeatherShowsChargingPercentage) var lockScreenWeatherShowsChargingPercentage
|
||||
@Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth
|
||||
@Default(.lockScreenWeatherShowsBatteryGauge) var lockScreenWeatherShowsBatteryGauge
|
||||
@Default(.lockScreenWeatherWidgetStyle) var lockScreenWeatherWidgetStyle
|
||||
@Default(.lockScreenWeatherTemperatureUnit) var lockScreenWeatherTemperatureUnit
|
||||
@Default(.lockScreenWeatherShowsAQI) var lockScreenWeatherShowsAQI
|
||||
@Default(.lockScreenWeatherUsesGaugeTint) var lockScreenWeatherUsesGaugeTint
|
||||
@Default(.lockScreenWeatherProviderSource) var lockScreenWeatherProviderSource
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
|
@ -324,6 +334,42 @@ struct GeneralSettings: View {
|
|||
Defaults.Toggle("Enable blur", key: .lockScreenPanelUsesBlur)
|
||||
Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget)
|
||||
Defaults.Toggle("Show lock screen weather", key: .enableLockScreenWeatherWidget)
|
||||
if enableLockScreenWeatherWidget {
|
||||
Picker("Widget layout", selection: $lockScreenWeatherWidgetStyle) {
|
||||
ForEach(LockScreenWeatherWidgetStyle.allCases) { style in
|
||||
Text(style.rawValue).tag(style)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
Picker("Weather data provider", selection: $lockScreenWeatherProviderSource) {
|
||||
ForEach(LockScreenWeatherProviderSource.allCases) { source in
|
||||
Text(source.displayName).tag(source)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
Picker("Temperature unit", selection: $lockScreenWeatherTemperatureUnit) {
|
||||
ForEach(LockScreenWeatherTemperatureUnit.allCases) { unit in
|
||||
Text(unit.rawValue).tag(unit)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
Defaults.Toggle("Show location label", key: .lockScreenWeatherShowsLocation)
|
||||
.disabled(lockScreenWeatherWidgetStyle == .circular)
|
||||
Defaults.Toggle("Show charging status", key: .lockScreenWeatherShowsCharging)
|
||||
if lockScreenWeatherShowsCharging {
|
||||
Defaults.Toggle("Show charging percentage", key: .lockScreenWeatherShowsChargingPercentage)
|
||||
}
|
||||
Defaults.Toggle("Show battery indicator", key: .lockScreenWeatherShowsBatteryGauge)
|
||||
Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth)
|
||||
Defaults.Toggle("Show AQI widget", key: .lockScreenWeatherShowsAQI)
|
||||
.disabled(!lockScreenWeatherProviderSource.supportsAirQuality)
|
||||
if !lockScreenWeatherProviderSource.supportsAirQuality {
|
||||
Text("Air quality requires the Open Meteo provider.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Defaults.Toggle("Use colored gauges", key: .lockScreenWeatherUsesGaugeTint)
|
||||
}
|
||||
|
||||
Button("Copy Latest Crash Report") {
|
||||
copyLatestCrashReport()
|
||||
|
|
@ -625,9 +671,6 @@ struct HUD: View {
|
|||
|
||||
Section {
|
||||
Toggle("Enable Built-in System HUD", isOn: $enableSystemHUD)
|
||||
.onChange(of: enableSystemHUD) { _ in
|
||||
// SystemHUDManager will automatically handle start/stop via its @Default observer
|
||||
}
|
||||
|
||||
if enableSystemHUD {
|
||||
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
||||
|
|
@ -666,6 +709,9 @@ struct HUD: View {
|
|||
|
||||
Section {
|
||||
Defaults.Toggle("Show Bluetooth device connections", key: .showBluetoothDeviceConnections)
|
||||
Defaults.Toggle("Use circular battery indicator", key: .useCircularBluetoothBatteryIndicator)
|
||||
Defaults.Toggle("Show battery percentage text in HUD", key: .showBluetoothBatteryPercentageText)
|
||||
Defaults.Toggle("Scroll device name in HUD", key: .showBluetoothDeviceNameMarquee)
|
||||
} header: {
|
||||
Text("Bluetooth Audio Devices")
|
||||
} footer: {
|
||||
|
|
@ -675,27 +721,30 @@ struct HUD: View {
|
|||
}
|
||||
|
||||
Section {
|
||||
let colorCodingDisabled = progressBarStyle == .segmented
|
||||
Defaults.Toggle("Color-coded battery display", key: .useColorCodedBatteryDisplay)
|
||||
.disabled(progressBarStyle != .gradient)
|
||||
.disabled(colorCodingDisabled)
|
||||
Defaults.Toggle("Color-coded volume display", key: .useColorCodedVolumeDisplay)
|
||||
.disabled(progressBarStyle != .gradient)
|
||||
.disabled(colorCodingDisabled)
|
||||
|
||||
if progressBarStyle == .gradient && (Defaults[.useColorCodedBatteryDisplay] || Defaults[.useColorCodedVolumeDisplay]) {
|
||||
if !colorCodingDisabled && (Defaults[.useColorCodedBatteryDisplay] || Defaults[.useColorCodedVolumeDisplay]) {
|
||||
Defaults.Toggle("Smooth color transitions", key: .useSmoothColorGradient)
|
||||
}
|
||||
|
||||
Defaults.Toggle("Show percentages beside progress bars", key: .showProgressPercentages)
|
||||
} header: {
|
||||
Text("Color-Coded Progress Bars")
|
||||
} footer: {
|
||||
if progressBarStyle != .gradient {
|
||||
Text("Color-coded displays are only available in Gradient mode. Switch Progressbar style to Gradient to enable this feature.")
|
||||
if progressBarStyle == .segmented {
|
||||
Text("Color-coded fills and smooth gradients are unavailable in Segmented mode. Switch to Hierarchical or Gradient to adjust these options.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
} else if Defaults[.useSmoothColorGradient] {
|
||||
Text("Smooth: Battery/Volume gradually transitions through all colors\nBattery: Green (high) → Yellow-Green → Yellow → Orange → Red (low)\nVolume: Green (quiet) → Yellow → Orange → Red (loud)")
|
||||
Text("Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
} else {
|
||||
Text("Discrete: Battery/Volume uses 3 distinct colors only\nBattery: Green (high), Yellow (medium), Red (low)\nVolume: Green (quiet), Yellow (medium), Red (loud)")
|
||||
Text("Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%).")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
|
|
@ -744,6 +793,7 @@ struct Media: View {
|
|||
@Default(.enableSneakPeek) private var enableSneakPeek
|
||||
@Default(.sneakPeekStyles) var sneakPeekStyles
|
||||
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
||||
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
|
@ -784,6 +834,10 @@ struct Media: View {
|
|||
customBadge(text: "Beta")
|
||||
}
|
||||
}
|
||||
Defaults.Toggle(key: .showMediaOutputControl) {
|
||||
Text("Replace repeat button with media output control")
|
||||
}
|
||||
.disabled(!showShuffleAndRepeat)
|
||||
} header: {
|
||||
Text("Media controls")
|
||||
}
|
||||
|
|
@ -1689,9 +1743,19 @@ func warningBadge(_ text: String, _ description: String) -> some View {
|
|||
struct TimerSettings: View {
|
||||
@ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@Default(.enableTimerFeature) var enableTimerFeature
|
||||
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 // 10 minutes default
|
||||
@Default(.timerPresets) private var timerPresets
|
||||
@Default(.timerIconColorMode) private var colorMode
|
||||
@Default(.timerSolidColor) private var solidColor
|
||||
@Default(.timerShowsCountdown) private var showsCountdown
|
||||
@Default(.timerShowsLabel) private var showsLabel
|
||||
@Default(.timerShowsProgress) private var showsProgress
|
||||
@Default(.timerProgressStyle) private var progressStyle
|
||||
@Default(.timerControlWindowEnabled) private var controlWindowEnabled
|
||||
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
|
||||
@State private var customHours: Int = 0
|
||||
@State private var customMinutes: Int = 10
|
||||
@State private var customSeconds: Int = 0
|
||||
@State private var showingResetConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
|
@ -1699,91 +1763,126 @@ struct TimerSettings: View {
|
|||
Defaults.Toggle("Enable timer feature", key: .enableTimerFeature)
|
||||
|
||||
if enableTimerFeature {
|
||||
Toggle(
|
||||
"Enable timer live activity",
|
||||
isOn: $coordinator.timerLiveActivityEnabled
|
||||
)
|
||||
Toggle("Enable timer live activity", isOn: $coordinator.timerLiveActivityEnabled)
|
||||
.animation(.easeInOut, value: coordinator.timerLiveActivityEnabled)
|
||||
}
|
||||
} header: {
|
||||
Text("Timer Feature")
|
||||
} footer: {
|
||||
Text("Enable or disable the timer functionality in the Dynamic Island. The live activity toggle controls whether timer progress is shown in the expanded view.")
|
||||
Text("Control timer availability and whether progress appears as a live activity while the notch is closed.")
|
||||
}
|
||||
|
||||
if enableTimerFeature {
|
||||
Group {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Custom Timer Duration")
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Default Custom Timer")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
Text("Minutes")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
TimerDurationStepperRow(title: "Hours", value: $customHours, range: 0...23)
|
||||
TimerDurationStepperRow(title: "Minutes", value: $customMinutes, range: 0...59)
|
||||
TimerDurationStepperRow(title: "Seconds", value: $customSeconds, range: 0...59)
|
||||
|
||||
Picker(selection: $customMinutes, label: Text("Minutes")) {
|
||||
ForEach(0...59, id: \.self) { minute in
|
||||
Text("\(minute)").tag(minute)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 100)
|
||||
}
|
||||
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
Text("Seconds")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Picker(selection: $customSeconds, label: Text("Seconds")) {
|
||||
ForEach(0...59, id: \.self) { second in
|
||||
Text("\(second)").tag(second)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 100)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Current custom timer:")
|
||||
Text("Current default:")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(customTimerDisplayText)
|
||||
.foregroundStyle(.primary)
|
||||
Text(customDurationDisplay)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.fontWeight(.medium)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Update Timer") {
|
||||
customTimerDuration = Double(customMinutes * 60 + customSeconds)
|
||||
}
|
||||
.disabled(customMinutes == 0 && customSeconds == 0)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.vertical, 4)
|
||||
.onChange(of: customHours) { _, _ in updateCustomDuration() }
|
||||
.onChange(of: customMinutes) { _, _ in updateCustomDuration() }
|
||||
.onChange(of: customSeconds) { _, _ in updateCustomDuration() }
|
||||
} header: {
|
||||
Text("Custom Timer")
|
||||
} footer: {
|
||||
Text("Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface.")
|
||||
Text("This duration powers the \"Custom\" option inside the timer popover for quick access.")
|
||||
}
|
||||
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Picker("Timer tint", selection: $colorMode) {
|
||||
ForEach(TimerIconColorMode.allCases) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
if colorMode == .solid {
|
||||
ColorPicker("Solid colour", selection: $solidColor, supportsOpacity: false)
|
||||
}
|
||||
|
||||
Toggle("Show timer name", isOn: $showsLabel)
|
||||
Toggle("Show countdown", isOn: $showsCountdown)
|
||||
Toggle("Show progress", isOn: $showsProgress)
|
||||
|
||||
Toggle("Show floating pause/stop controls", isOn: $controlWindowEnabled)
|
||||
.disabled(showsLabel)
|
||||
.help("These controls sit beside the notch while a timer runs. They require the timer name to stay hidden for spacing.")
|
||||
|
||||
Picker("Progress style", selection: $progressStyle) {
|
||||
ForEach(TimerProgressStyle.allCases) { style in
|
||||
Text(style.rawValue).tag(style)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(!showsProgress)
|
||||
} header: {
|
||||
Text("Appearance")
|
||||
} footer: {
|
||||
Text("Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars.")
|
||||
}
|
||||
|
||||
Section {
|
||||
if timerPresets.isEmpty {
|
||||
Text("No presets configured. Add a preset to make it appear in the timer popover.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
ForEach(timerPresets.indices, id: \.self) { index in
|
||||
TimerPresetEditorRow(
|
||||
preset: $timerPresets[index],
|
||||
isFirst: index == timerPresets.startIndex,
|
||||
isLast: index == timerPresets.index(before: timerPresets.endIndex),
|
||||
moveUp: { movePresetUp(index) },
|
||||
moveDown: { movePresetDown(index) },
|
||||
remove: { removePreset(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button(action: addPreset) {
|
||||
Label("Add Preset", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(role: .destructive, action: { showingResetConfirmation = true }) {
|
||||
Label("Restore Defaults", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.confirmationDialog("Restore default timer presets?", isPresented: $showingResetConfirmation, titleVisibility: .visible) {
|
||||
Button("Restore", role: .destructive, action: resetPresets)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Timer Presets")
|
||||
} footer: {
|
||||
Text("Presets show up inside the timer popover with the configured name, duration, and accent colour. Reorder them to change the display order.")
|
||||
}
|
||||
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Timer Sound")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
Spacer()
|
||||
Button("Choose File") {
|
||||
selectCustomTimerSound()
|
||||
}
|
||||
Button("Choose File", action: selectCustomTimerSound)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
|
|
@ -1806,30 +1905,79 @@ struct TimerSettings: View {
|
|||
} header: {
|
||||
Text("Timer Sound")
|
||||
} footer: {
|
||||
Text("Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF.")
|
||||
Text("Select a custom sound to play when a timer ends. Supported formats include MP3, M4A, WAV, and AIFF.")
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if showsLabel {
|
||||
controlWindowEnabled = false
|
||||
}
|
||||
}
|
||||
.onChange(of: showsLabel) { _, show in
|
||||
if show {
|
||||
controlWindowEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Timer")
|
||||
.onAppear {
|
||||
let totalMinutes = Int(customTimerDuration) / 60
|
||||
customMinutes = totalMinutes
|
||||
customSeconds = Int(customTimerDuration) % 60
|
||||
.onAppear { syncCustomDuration() }
|
||||
.onChange(of: customTimerDuration) { _, newValue in syncCustomDuration(newValue) }
|
||||
}
|
||||
|
||||
private var customDurationDisplay: String {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = customTimerDuration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
|
||||
formatter.zeroFormattingBehavior = [.pad]
|
||||
return formatter.string(from: customTimerDuration) ?? "0:00"
|
||||
}
|
||||
|
||||
private func syncCustomDuration(_ value: Double? = nil) {
|
||||
let baseValue = value ?? customTimerDuration
|
||||
let components = TimerPreset.components(for: baseValue)
|
||||
customHours = components.hours
|
||||
customMinutes = components.minutes
|
||||
customSeconds = components.seconds
|
||||
}
|
||||
|
||||
private func updateCustomDuration() {
|
||||
let duration = TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds)
|
||||
customTimerDuration = duration
|
||||
}
|
||||
|
||||
private func addPreset() {
|
||||
let nextIndex = timerPresets.count + 1
|
||||
let defaultColor = Defaults[.accentColor]
|
||||
let newPreset = TimerPreset(name: "Preset \(nextIndex)", duration: 5 * 60, color: defaultColor)
|
||||
_ = withAnimation(.smooth) {
|
||||
timerPresets.append(newPreset)
|
||||
}
|
||||
}
|
||||
|
||||
private var customTimerDisplayText: String {
|
||||
let totalMinutes = Int(customTimerDuration) / 60
|
||||
let seconds = Int(customTimerDuration) % 60
|
||||
let hours = totalMinutes / 60
|
||||
let minutes = totalMinutes % 60
|
||||
private func movePresetUp(_ index: Int) {
|
||||
guard index > timerPresets.startIndex else { return }
|
||||
_ = withAnimation(.smooth) {
|
||||
timerPresets.swapAt(index, index - 1)
|
||||
}
|
||||
}
|
||||
|
||||
if hours > 0 {
|
||||
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
|
||||
} else if minutes > 0 {
|
||||
return "\(minutes):\(String(format: "%02d", seconds))"
|
||||
} else {
|
||||
return "\(seconds)s"
|
||||
private func movePresetDown(_ index: Int) {
|
||||
guard index < timerPresets.index(before: timerPresets.endIndex) else { return }
|
||||
_ = withAnimation(.smooth) {
|
||||
timerPresets.swapAt(index, index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func removePreset(_ index: Int) {
|
||||
guard timerPresets.indices.contains(index) else { return }
|
||||
_ = withAnimation(.smooth) {
|
||||
timerPresets.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
private func resetPresets() {
|
||||
_ = withAnimation(.smooth) {
|
||||
timerPresets = TimerPreset.defaultPresets
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1849,6 +1997,146 @@ struct TimerSettings: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct TimerDurationStepperRow: View {
|
||||
let title: String
|
||||
@Binding var value: Int
|
||||
let range: ClosedRange<Int>
|
||||
|
||||
var body: some View {
|
||||
Stepper(value: $value, in: range) {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
Text("\(value)")
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimerPresetEditorRow: View {
|
||||
@Binding var preset: TimerPreset
|
||||
let isFirst: Bool
|
||||
let isLast: Bool
|
||||
let moveUp: () -> Void
|
||||
let moveDown: () -> Void
|
||||
let remove: () -> Void
|
||||
|
||||
private var components: TimerPreset.DurationComponents {
|
||||
TimerPreset.components(for: preset.duration)
|
||||
}
|
||||
|
||||
private var hoursBinding: Binding<Int> {
|
||||
Binding(
|
||||
get: { components.hours },
|
||||
set: { updateDuration(hours: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private var minutesBinding: Binding<Int> {
|
||||
Binding(
|
||||
get: { components.minutes },
|
||||
set: { updateDuration(minutes: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private var secondsBinding: Binding<Int> {
|
||||
Binding(
|
||||
get: { components.seconds },
|
||||
set: { updateDuration(seconds: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private var colorBinding: Binding<Color> {
|
||||
Binding(
|
||||
get: { preset.color },
|
||||
set: { preset.updateColor($0) }
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(preset.color.gradient)
|
||||
.frame(width: 20, height: 20)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
|
||||
TextField("Preset name", text: $preset.name)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(preset.formattedDuration)
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 16) {
|
||||
TimerPresetComponentControl(title: "Hours", value: hoursBinding, range: 0...23)
|
||||
TimerPresetComponentControl(title: "Minutes", value: minutesBinding, range: 0...59)
|
||||
TimerPresetComponentControl(title: "Seconds", value: secondsBinding, range: 0...59)
|
||||
}
|
||||
|
||||
ColorPicker("Accent colour", selection: colorBinding, supportsOpacity: false)
|
||||
.frame(maxWidth: 240, alignment: .leading)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button(action: moveUp) {
|
||||
Label("Move Up", systemImage: "chevron.up")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(isFirst)
|
||||
|
||||
Button(action: moveDown) {
|
||||
Label("Move Down", systemImage: "chevron.down")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(isLast)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(role: .destructive, action: remove) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private func updateDuration(hours: Int? = nil, minutes: Int? = nil, seconds: Int? = nil) {
|
||||
var values = components
|
||||
if let hours { values.hours = hours }
|
||||
if let minutes { values.minutes = minutes }
|
||||
if let seconds { values.seconds = seconds }
|
||||
preset.duration = TimerPreset.duration(from: values)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimerPresetComponentControl: View {
|
||||
let title: String
|
||||
@Binding var value: Int
|
||||
let range: ClosedRange<Int>
|
||||
|
||||
var body: some View {
|
||||
Stepper(value: $value, in: range) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(value)")
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
}
|
||||
}
|
||||
.frame(width: 110, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct StatsSettings: View {
|
||||
@ObservedObject var statsManager = StatsManager.shared
|
||||
@Default(.enableStatsFeature) var enableStatsFeature
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ struct TabModel: Identifiable {
|
|||
|
||||
struct TabSelectionView: View {
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@Default(.enableTimerFeature) var enableTimerFeature
|
||||
@Default(.enableStatsFeature) var enableStatsFeature
|
||||
@Default(.enableColorPickerFeature) var enableColorPickerFeature
|
||||
@Namespace var animation
|
||||
|
|
@ -29,11 +28,6 @@ struct TabSelectionView: View {
|
|||
|
||||
tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf))
|
||||
|
||||
// Timer tab only shown when timer feature is enabled
|
||||
if Defaults[.enableTimerFeature] {
|
||||
tabsArray.append(TabModel(label: "Timer", icon: "timer", view: .timer))
|
||||
}
|
||||
|
||||
// Stats tab only shown when stats feature is enabled
|
||||
if Defaults[.enableStatsFeature] {
|
||||
tabsArray.append(TabModel(label: "Stats", icon: "chart.xyaxis.line", view: .stats))
|
||||
|
|
|
|||
88
DynamicIsland/components/Timer/TimerControlOverlay.swift
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import SwiftUI
|
||||
|
||||
struct TimerControlOverlay: View {
|
||||
let notchHeight: CGFloat
|
||||
let cornerRadius: CGFloat
|
||||
|
||||
@ObservedObject private var timerManager = TimerManager.shared
|
||||
|
||||
private var pauseIcon: String {
|
||||
timerManager.isPaused ? "play.fill" : "pause.fill"
|
||||
}
|
||||
|
||||
private var pauseForeground: Color {
|
||||
.white
|
||||
}
|
||||
|
||||
private var helpText: String {
|
||||
timerManager.isPaused ? "Resume" : "Pause"
|
||||
}
|
||||
|
||||
private var buttonSize: CGFloat {
|
||||
max(notchHeight - 20, 22)
|
||||
}
|
||||
|
||||
private var iconSize: CGFloat {
|
||||
14
|
||||
}
|
||||
|
||||
private var windowCornerRadius: CGFloat {
|
||||
max(cornerRadius - 6, 12)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: togglePause) {
|
||||
Image(systemName: pauseIcon)
|
||||
.font(.system(size: iconSize, weight: .semibold))
|
||||
.frame(width: buttonSize, height: buttonSize)
|
||||
.foregroundStyle(pauseForeground)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.help(helpText)
|
||||
|
||||
Button(action: stopTimer) {
|
||||
Image(systemName: "stop.fill")
|
||||
.font(.system(size: iconSize, weight: .semibold))
|
||||
.frame(width: buttonSize, height: buttonSize)
|
||||
.foregroundStyle(Color.red)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.help("Stop")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: notchHeight)
|
||||
.frame(minWidth: buttonSize * 2 + 32)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: windowCornerRadius, style: .continuous)
|
||||
.fill(Color.black.opacity(0.9))
|
||||
}
|
||||
.compositingGroup()
|
||||
.animation(.smooth(duration: 0.2), value: timerManager.isPaused)
|
||||
.animation(.smooth(duration: 0.2), value: timerManager.isFinished)
|
||||
.animation(.smooth(duration: 0.2), value: timerManager.isOvertime)
|
||||
}
|
||||
|
||||
private func togglePause() {
|
||||
if timerManager.isPaused {
|
||||
timerManager.resumeTimer()
|
||||
} else {
|
||||
timerManager.pauseTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
#if os(macOS)
|
||||
TimerControlWindowManager.shared.hide(animated: true)
|
||||
#endif
|
||||
timerManager.stopTimer()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TimerControlOverlay(notchHeight: 34, cornerRadius: 14)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.2))
|
||||
}
|
||||
|
|
@ -6,95 +6,43 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Defaults
|
||||
|
||||
struct TimerIconAnimation: View {
|
||||
@ObservedObject var timerManager = TimerManager.shared
|
||||
@State private var pulseScale: CGFloat = 1.0
|
||||
@State private var rotationAngle: Double = 0
|
||||
@Default(.timerIconColorMode) private var colorMode
|
||||
@Default(.timerSolidColor) private var solidColor
|
||||
@Default(.timerPresets) private var timerPresets
|
||||
@Default(.accentColor) private var accentColor
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background circle with timer color
|
||||
Circle()
|
||||
.fill(timerManager.timerColor.gradient)
|
||||
.fill(resolvedColor.gradient)
|
||||
.frame(width: 24, height: 24)
|
||||
.scaleEffect(pulseScale)
|
||||
.animation(
|
||||
timerManager.isTimerActive && !timerManager.isPaused
|
||||
? .easeInOut(duration: 1.0).repeatForever(autoreverses: true)
|
||||
: .easeInOut(duration: 0.3),
|
||||
value: pulseScale
|
||||
)
|
||||
|
||||
// Timer icon
|
||||
Image(systemName: "timer")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.rotationEffect(.degrees(rotationAngle))
|
||||
.animation(
|
||||
timerManager.isTimerActive && !timerManager.isPaused
|
||||
? .linear(duration: 2.0).repeatForever(autoreverses: false)
|
||||
: .easeInOut(duration: 0.3),
|
||||
value: rotationAngle
|
||||
)
|
||||
|
||||
// Progress ring
|
||||
Circle()
|
||||
.trim(from: 0, to: timerManager.progress)
|
||||
.stroke(
|
||||
Color.white.opacity(0.8),
|
||||
style: StrokeStyle(lineWidth: 2, lineCap: .round)
|
||||
)
|
||||
.frame(width: 26, height: 26)
|
||||
.rotationEffect(.degrees(-90)) // Start from top
|
||||
.animation(.easeInOut(duration: 0.5), value: timerManager.progress)
|
||||
}
|
||||
.onAppear {
|
||||
startAnimations()
|
||||
}
|
||||
.onChange(of: timerManager.isTimerActive) { _, isActive in
|
||||
if isActive {
|
||||
startAnimations()
|
||||
} else {
|
||||
stopAnimations()
|
||||
}
|
||||
}
|
||||
.onChange(of: timerManager.isPaused) { _, isPaused in
|
||||
if isPaused {
|
||||
stopAnimations()
|
||||
} else if timerManager.isTimerActive {
|
||||
startAnimations()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startAnimations() {
|
||||
guard timerManager.isTimerActive && !timerManager.isPaused else { return }
|
||||
|
||||
// Start pulsing
|
||||
pulseScale = 1.1
|
||||
|
||||
// Start rotation
|
||||
withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) {
|
||||
rotationAngle = 360
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopAnimations() {
|
||||
// Stop pulsing
|
||||
pulseScale = 1.0
|
||||
|
||||
// Stop rotation
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
rotationAngle = 0
|
||||
private var resolvedColor: Color {
|
||||
guard timerManager.isTimerActive else { return accentColor }
|
||||
switch colorMode {
|
||||
case .adaptive:
|
||||
if let presetId = timerManager.activePresetId,
|
||||
let preset = timerPresets.first(where: { $0.id == presetId }) {
|
||||
return preset.color
|
||||
}
|
||||
return timerManager.timerColor
|
||||
case .solid:
|
||||
return solidColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimerIconView: NSViewRepresentable {
|
||||
@ObservedObject var timerManager = TimerManager.shared
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let hostingView = NSHostingView(rootView: TimerIconAnimation())
|
||||
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
|
|
|||
|
|
@ -8,66 +8,470 @@
|
|||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
typealias PlatformFont = NSFont
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
typealias PlatformFont = UIFont
|
||||
#endif
|
||||
|
||||
struct TimerLiveActivity: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@ObservedObject var timerManager = TimerManager.shared
|
||||
@State private var isHovering: Bool = false
|
||||
@State private var gestureProgress: CGFloat = 0
|
||||
@State private var showTransientLabel: Bool = false
|
||||
@State private var labelHideTask: DispatchWorkItem?
|
||||
@State private var isControlWindowVisible: Bool = false
|
||||
@Default(.timerShowsCountdown) private var showsCountdown
|
||||
@Default(.timerShowsProgress) private var showsProgress
|
||||
@Default(.timerShowsLabel) private var showsLabel
|
||||
@Default(.timerProgressStyle) private var progressStyle
|
||||
@Default(.timerIconColorMode) private var colorMode
|
||||
@Default(.timerSolidColor) private var solidColor
|
||||
@Default(.timerPresets) private var timerPresets
|
||||
@Default(.timerControlWindowEnabled) private var controlWindowEnabled
|
||||
|
||||
private var notchContentHeight: CGFloat {
|
||||
max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))
|
||||
}
|
||||
|
||||
private var wingPadding: CGFloat { 22 }
|
||||
private var ringStrokeWidth: CGFloat { 3 }
|
||||
private var transientLabelDuration: TimeInterval { 4 }
|
||||
|
||||
private var ringWrapsIcon: Bool {
|
||||
showsRingProgress && showsCountdown
|
||||
}
|
||||
|
||||
private var ringOnRight: Bool {
|
||||
showsRingProgress && !ringWrapsIcon
|
||||
}
|
||||
|
||||
private var iconWidth: CGFloat {
|
||||
ringWrapsIcon ? max(notchContentHeight - 6, 28) : max(0, notchContentHeight)
|
||||
}
|
||||
|
||||
private var infoContentWidth: CGFloat {
|
||||
guard showsInfoSection else { return 0 }
|
||||
if shouldDisplayLabel {
|
||||
let textWidth = min(max(titleTextWidth, 44), 220)
|
||||
return textWidth
|
||||
} else {
|
||||
return min(max(notchContentHeight * 1.4, 64), 220)
|
||||
}
|
||||
}
|
||||
|
||||
private var infoWidth: CGFloat {
|
||||
guard showsInfoSection else { return 0 }
|
||||
return infoContentWidth + 18
|
||||
}
|
||||
|
||||
private var leftWingWidth: CGFloat {
|
||||
var width = iconWidth + wingPadding
|
||||
if showsInfoSection {
|
||||
width += 8 + infoWidth
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
private var ringWidth: CGFloat {
|
||||
ringOnRight ? 30 : 0
|
||||
}
|
||||
|
||||
private var rightWingWidth: CGFloat {
|
||||
var width = wingPadding
|
||||
if ringOnRight {
|
||||
width += ringWidth
|
||||
}
|
||||
if ringOnRight && showsCountdown {
|
||||
width += 8
|
||||
}
|
||||
if showsCountdown {
|
||||
width += countdownWidth
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
private var titleTextWidth: CGFloat {
|
||||
measureTextWidth(timerManager.timerName, font: systemFont(size: 12, weight: .medium))
|
||||
}
|
||||
|
||||
private var countdownTextWidth: CGFloat {
|
||||
measureTextWidth(timerManager.formattedRemainingTime(), font: monospacedDigitFont(size: 13, weight: .semibold))
|
||||
}
|
||||
|
||||
private var countdownWidth: CGFloat {
|
||||
guard showsCountdown else { return 0 }
|
||||
return max(countdownTextWidth + 16, 72)
|
||||
}
|
||||
|
||||
private var clampedProgress: Double {
|
||||
min(max(timerManager.progress, 0), 1)
|
||||
}
|
||||
|
||||
private var glyphColor: Color {
|
||||
switch colorMode {
|
||||
case .adaptive:
|
||||
return activePresetColor ?? timerManager.timerColor
|
||||
case .solid:
|
||||
return solidColor
|
||||
}
|
||||
}
|
||||
|
||||
private var showsRingProgress: Bool {
|
||||
showsProgress && progressStyle == .ring
|
||||
}
|
||||
|
||||
private var showsBarProgress: Bool {
|
||||
showsProgress && progressStyle == .bar
|
||||
}
|
||||
|
||||
private var shouldDisplayLabel: Bool {
|
||||
showsLabel || showTransientLabel
|
||||
}
|
||||
|
||||
private var showsInfoSection: Bool {
|
||||
shouldDisplayLabel || (showsBarProgress && !showsCountdown)
|
||||
}
|
||||
|
||||
private var activePresetColor: Color? {
|
||||
guard let presetId = timerManager.activePresetId else { return nil }
|
||||
return timerPresets.first { $0.id == presetId }?.color
|
||||
}
|
||||
|
||||
private var shouldShowControlWindow: Bool {
|
||||
controlWindowEnabled && !shouldDisplayLabel && timerManager.isTimerActive
|
||||
}
|
||||
|
||||
private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat {
|
||||
let attributes: [NSAttributedString.Key: Any] = [.font: font]
|
||||
let width = NSAttributedString(string: text, attributes: attributes).size().width
|
||||
return CGFloat(ceil(width))
|
||||
}
|
||||
|
||||
private func systemFont(size: CGFloat, weight: PlatformFont.Weight) -> PlatformFont {
|
||||
#if canImport(AppKit)
|
||||
return NSFont.systemFont(ofSize: size, weight: weight)
|
||||
#else
|
||||
return UIFont.systemFont(ofSize: size, weight: weight)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func monospacedDigitFont(size: CGFloat, weight: PlatformFont.Weight) -> PlatformFont {
|
||||
#if canImport(AppKit)
|
||||
return NSFont.monospacedDigitSystemFont(ofSize: size, weight: weight)
|
||||
#else
|
||||
return UIFont.monospacedDigitSystemFont(ofSize: size, weight: weight)
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
// Left side - Timer icon animation
|
||||
timerIconSection
|
||||
|
||||
// Center - Expandable timer info (similar to music banner)
|
||||
timerInfoSection
|
||||
|
||||
// Right side - Timer countdown text
|
||||
timerCountdownSection
|
||||
HStack(spacing: 0) {
|
||||
Color.clear
|
||||
.frame(width: leftWingWidth, height: notchContentHeight)
|
||||
.background(alignment: .leading) {
|
||||
HStack(spacing: showsInfoSection ? 8 : 0) {
|
||||
iconSection
|
||||
if showsInfoSection {
|
||||
infoSection
|
||||
}
|
||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
||||
}
|
||||
.padding(.leading, wingPadding / 2)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var timerIconSection: some View {
|
||||
HStack {
|
||||
// Simple timer icon with color, no background circle or animations
|
||||
Image(systemName: "timer")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(timerManager.timerColor)
|
||||
.frame(width: 24, height: 24)
|
||||
.frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
|
||||
height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center)
|
||||
}
|
||||
.frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
|
||||
height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)))
|
||||
}
|
||||
|
||||
private var timerInfoSection: some View {
|
||||
Rectangle()
|
||||
.fill(.black)
|
||||
.frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0))
|
||||
.frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0), height: notchContentHeight)
|
||||
|
||||
Color.clear
|
||||
.frame(width: rightWingWidth, height: notchContentHeight)
|
||||
.background(alignment: .trailing) {
|
||||
HStack(spacing: ringOnRight && showsCountdown ? 8 : 0) {
|
||||
if ringOnRight {
|
||||
ringSection
|
||||
}
|
||||
if showsCountdown {
|
||||
countdownSection
|
||||
}
|
||||
}
|
||||
.padding(.trailing, wingPadding / 2)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
||||
.contentShape(Rectangle())
|
||||
.onHover { hovering in
|
||||
withAnimation(.smooth(duration: 0.18)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onAppear {
|
||||
syncControlWindow(forceRefresh: true)
|
||||
if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime {
|
||||
triggerTransientLabel()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
hideControlWindow()
|
||||
}
|
||||
.onChange(of: timerManager.isTimerActive) { _, isActive in
|
||||
if isActive {
|
||||
if !timerManager.isFinished && !timerManager.isOvertime {
|
||||
syncControlWindow(forceRefresh: true)
|
||||
triggerTransientLabel()
|
||||
}
|
||||
} else {
|
||||
hideControlWindow()
|
||||
cancelTransientLabel()
|
||||
showTransientLabel = false
|
||||
isHovering = false
|
||||
}
|
||||
}
|
||||
.onChange(of: timerManager.timerName) { _, _ in
|
||||
if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime {
|
||||
syncControlWindow(forceRefresh: true)
|
||||
triggerTransientLabel()
|
||||
}
|
||||
}
|
||||
.onChange(of: timerManager.isFinished) { _, finished in
|
||||
if finished {
|
||||
cancelTransientLabel()
|
||||
withAnimation(.smooth) {
|
||||
showTransientLabel = true
|
||||
isHovering = false
|
||||
}
|
||||
syncControlWindow()
|
||||
}
|
||||
}
|
||||
.onChange(of: timerManager.isOvertime) { _, overtime in
|
||||
if overtime {
|
||||
cancelTransientLabel()
|
||||
withAnimation(.smooth) {
|
||||
showTransientLabel = true
|
||||
isHovering = false
|
||||
}
|
||||
syncControlWindow()
|
||||
}
|
||||
}
|
||||
.onChange(of: timerManager.isPaused) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: vm.closedNotchSize) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: vm.screen) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: vm.hideOnClosed) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: controlWindowEnabled) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: showsLabel) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: showTransientLabel) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: showsCountdown) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: showsProgress) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: progressStyle) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var timerCountdownSection: some View {
|
||||
HStack {
|
||||
VStack(spacing: 2) {
|
||||
// Remaining time
|
||||
private var iconSection: some View {
|
||||
let baseDiameter = ringWrapsIcon ? iconWidth : iconWidth
|
||||
let ringDiameter = ringWrapsIcon ? max(min(baseDiameter, notchContentHeight - 2), 22) : iconWidth
|
||||
let iconSize = ringWrapsIcon ? max(ringDiameter - 12, 16) : max(18, iconWidth - 6)
|
||||
|
||||
return ZStack {
|
||||
if ringWrapsIcon {
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth)
|
||||
.frame(width: ringDiameter, height: ringDiameter)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: clampedProgress)
|
||||
.stroke(glyphColor, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.smooth(duration: 0.25), value: clampedProgress)
|
||||
.frame(width: ringDiameter, height: ringDiameter)
|
||||
}
|
||||
|
||||
Image(systemName: "timer")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(glyphColor)
|
||||
.frame(width: iconSize, height: iconSize)
|
||||
}
|
||||
.frame(width: ringWrapsIcon ? ringDiameter : iconWidth,
|
||||
height: notchContentHeight,
|
||||
alignment: .center)
|
||||
}
|
||||
|
||||
private var infoSection: some View {
|
||||
let availableWidth = max(0, infoWidth - 10)
|
||||
let safeWidth = max(44, availableWidth - 6)
|
||||
let resolvedTextWidth = min(max(titleTextWidth, 44), safeWidth)
|
||||
let marqueeLabel = shouldDisplayLabel && (timerManager.isFinished || timerManager.isOvertime || titleTextWidth > availableWidth)
|
||||
let showsBarHere = showsBarProgress && !showsCountdown
|
||||
let barWidth = shouldDisplayLabel ? resolvedTextWidth : availableWidth
|
||||
|
||||
return Rectangle()
|
||||
.fill(.black)
|
||||
.frame(width: infoWidth, height: notchContentHeight)
|
||||
.overlay(alignment: .leading) {
|
||||
VStack(alignment: .leading, spacing: showsBarHere ? 4 : 0) {
|
||||
if shouldDisplayLabel {
|
||||
if marqueeLabel {
|
||||
MarqueeText(
|
||||
.constant(timerManager.timerName),
|
||||
font: .system(size: 12, weight: .medium),
|
||||
nsFont: .callout,
|
||||
textColor: .white,
|
||||
minDuration: 0.25,
|
||||
frameWidth: resolvedTextWidth
|
||||
)
|
||||
} else {
|
||||
Text(timerManager.timerName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.white)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
.frame(width: resolvedTextWidth, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
if showsBarHere {
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.12))
|
||||
.frame(width: barWidth, height: 3)
|
||||
.overlay(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(glyphColor)
|
||||
.frame(width: barWidth * max(0, CGFloat(clampedProgress)))
|
||||
.animation(.smooth(duration: 0.25), value: clampedProgress)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
.padding(.trailing, 6)
|
||||
}
|
||||
.animation(.smooth, value: timerManager.isFinished)
|
||||
}
|
||||
|
||||
private var ringSection: some View {
|
||||
let diameter = max(min(notchContentHeight - 4, 26), 20)
|
||||
return ZStack {
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth)
|
||||
Circle()
|
||||
.trim(from: 0, to: clampedProgress)
|
||||
.stroke(glyphColor, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.smooth(duration: 0.25), value: clampedProgress)
|
||||
}
|
||||
.frame(width: diameter, height: diameter)
|
||||
.frame(width: ringWidth, height: notchContentHeight, alignment: .center)
|
||||
}
|
||||
|
||||
private var countdownSection: some View {
|
||||
let barWidth = max(countdownTextWidth, 1)
|
||||
return VStack(spacing: 4) {
|
||||
Text(timerManager.formattedRemainingTime())
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(timerManager.isOvertime ? .red : .white)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.smooth(duration: 0.25), value: timerManager.remainingTime)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
|
||||
// Progress indicator
|
||||
if timerManager.totalDuration > 0 {
|
||||
ProgressView(value: timerManager.progress)
|
||||
.progressViewStyle(LinearProgressViewStyle(tint: timerManager.timerColor))
|
||||
.frame(width: 30, height: 2)
|
||||
if showsBarProgress {
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.12))
|
||||
.frame(width: barWidth, height: 3)
|
||||
.overlay(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(glyphColor)
|
||||
.frame(width: barWidth * max(0, CGFloat(clampedProgress)))
|
||||
.animation(.smooth(duration: 0.25), value: clampedProgress)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.trailing, 8)
|
||||
.frame(width: countdownWidth,
|
||||
height: notchContentHeight, alignment: .center)
|
||||
}
|
||||
.frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
|
||||
height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center)
|
||||
|
||||
private func triggerTransientLabel() {
|
||||
guard !showsLabel else { return }
|
||||
cancelTransientLabel()
|
||||
withAnimation(.smooth) {
|
||||
showTransientLabel = true
|
||||
}
|
||||
let task = DispatchWorkItem {
|
||||
withAnimation(.smooth) {
|
||||
showTransientLabel = false
|
||||
}
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
labelHideTask = task
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + transientLabelDuration, execute: task)
|
||||
}
|
||||
|
||||
private func cancelTransientLabel() {
|
||||
labelHideTask?.cancel()
|
||||
labelHideTask = nil
|
||||
}
|
||||
|
||||
private func syncControlWindow(forceRefresh: Bool = false) {
|
||||
#if os(macOS)
|
||||
let notchAvailable = vm.effectiveClosedNotchHeight > 0 && vm.closedNotchSize.width > 0
|
||||
let targetVisible = shouldShowControlWindow && notchAvailable
|
||||
if targetVisible {
|
||||
let metrics = currentControlWindowMetrics()
|
||||
if !isControlWindowVisible {
|
||||
let didPresent = TimerControlWindowManager.shared.present(using: vm, metrics: metrics)
|
||||
isControlWindowVisible = didPresent
|
||||
} else if forceRefresh {
|
||||
let didRefresh = TimerControlWindowManager.shared.refresh(using: vm, metrics: metrics)
|
||||
isControlWindowVisible = didRefresh
|
||||
if !didRefresh {
|
||||
TimerControlWindowManager.shared.hide()
|
||||
}
|
||||
}
|
||||
} else if isControlWindowVisible {
|
||||
TimerControlWindowManager.shared.hide()
|
||||
isControlWindowVisible = false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func hideControlWindow() {
|
||||
#if os(macOS)
|
||||
if isControlWindowVisible {
|
||||
TimerControlWindowManager.shared.hide()
|
||||
isControlWindowVisible = false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func currentControlWindowMetrics() -> TimerControlWindowMetrics {
|
||||
TimerControlWindowMetrics(
|
||||
notchHeight: max(vm.closedNotchSize.height, vm.effectiveClosedNotchHeight),
|
||||
notchWidth: vm.closedNotchSize.width + (isHovering ? 8 : 0),
|
||||
rightWingWidth: rightWingWidth,
|
||||
cornerRadius: cornerRadiusInsets.closed.bottom,
|
||||
spacing: 6
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +481,6 @@ struct TimerLiveActivity: View {
|
|||
.frame(width: 300, height: 32)
|
||||
.background(.black)
|
||||
.onAppear {
|
||||
// Start a demo timer for preview
|
||||
TimerManager.shared.startDemoTimer(duration: 300)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
362
DynamicIsland/components/Timer/TimerPopover.swift
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
//
|
||||
// TimerPopover.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by GitHub Copilot on 2025-11-01.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
struct TimerPopover: View {
|
||||
@ObservedObject var timerManager = TimerManager.shared
|
||||
@Default(.timerPresets) private var timerPresets
|
||||
@Default(.timerShowsCountdown) private var showsCountdown
|
||||
@Default(.timerShowsProgress) private var showsProgress
|
||||
@Default(.timerProgressStyle) private var progressStyle
|
||||
@Default(.timerIconColorMode) private var colorMode
|
||||
@Default(.timerSolidColor) private var solidColor
|
||||
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var customHours: Int = 0
|
||||
@State private var customMinutes: Int = 10
|
||||
@State private var customSeconds: Int = 0
|
||||
|
||||
private var customDurationInSeconds: TimeInterval {
|
||||
TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
HeaderView(statusText: statusText)
|
||||
|
||||
if timerManager.isTimerActive {
|
||||
ActiveTimerSection(timerManager: timerManager)
|
||||
} else {
|
||||
CustomTimerSection(hours: $customHours, minutes: $customMinutes, seconds: $customSeconds, startAction: startCustomTimer)
|
||||
.onChange(of: customHours) { _, _ in updateStoredCustomDuration() }
|
||||
.onChange(of: customMinutes) { _, _ in updateStoredCustomDuration() }
|
||||
.onChange(of: customSeconds) { _, _ in updateStoredCustomDuration() }
|
||||
}
|
||||
|
||||
Divider()
|
||||
.padding(.horizontal, -8)
|
||||
|
||||
PresetList(presets: timerPresets, activePresetId: timerManager.activePresetId, startAction: startPreset)
|
||||
.animation(.smooth, value: timerManager.activePresetId)
|
||||
.frame(maxHeight: 200)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 300)
|
||||
.background(
|
||||
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
|
||||
.cornerRadius(12)
|
||||
)
|
||||
.onAppear {
|
||||
syncCustomDuration(with: customTimerDuration)
|
||||
}
|
||||
.onChange(of: customTimerDuration) { _, newValue in
|
||||
syncCustomDuration(with: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if timerManager.isOvertime {
|
||||
return "Overtime"
|
||||
} else if timerManager.isPaused {
|
||||
return "Paused"
|
||||
} else if timerManager.isTimerActive {
|
||||
return "Running"
|
||||
} else {
|
||||
return "Ready"
|
||||
}
|
||||
}
|
||||
|
||||
private func syncCustomDuration(with value: Double) {
|
||||
let components = TimerPreset.components(for: value)
|
||||
customHours = components.hours
|
||||
customMinutes = components.minutes
|
||||
customSeconds = components.seconds
|
||||
}
|
||||
|
||||
private func updateStoredCustomDuration() {
|
||||
let newValue = customDurationInSeconds
|
||||
customTimerDuration = newValue
|
||||
}
|
||||
|
||||
private func startCustomTimer() {
|
||||
let duration = customDurationInSeconds
|
||||
guard duration > 0 else { return }
|
||||
withAnimation(.smooth) {
|
||||
timerManager.startTimer(duration: duration, name: "Custom Timer")
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func startPreset(_ preset: TimerPreset) {
|
||||
withAnimation(.smooth) {
|
||||
timerManager.startTimer(duration: preset.duration, name: preset.name, preset: preset)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private struct HeaderView: View {
|
||||
let statusText: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
TimerIconAnimation()
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Timer")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Text(statusText)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActiveTimerSection: View {
|
||||
@ObservedObject var timerManager: TimerManager
|
||||
@Default(.timerShowsProgress) private var showsProgress
|
||||
@Default(.timerProgressStyle) private var progressStyle
|
||||
@Default(.timerIconColorMode) private var colorMode
|
||||
@Default(.timerSolidColor) private var solidColor
|
||||
@Default(.timerPresets) private var timerPresets
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(timerManager.timerName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(timerManager.formattedRemainingTime())
|
||||
.font(.system(size: 28, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(timerManager.isOvertime ? Color.red : Color.primary)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
|
||||
if showsProgress && progressStyle == .bar {
|
||||
ProgressView(value: min(timerManager.progress, 1.0))
|
||||
.progressViewStyle(LinearProgressViewStyle(tint: progressTint))
|
||||
.animation(.smooth(duration: 0.2), value: timerManager.progress)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(action: togglePause) {
|
||||
Label(timerManager.isPaused ? "Resume" : "Pause", systemImage: timerManager.isPaused ? "play.fill" : "pause.fill")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button(role: .destructive, action: stopTimer) {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(Color.white.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.animation(.smooth, value: timerManager.isPaused)
|
||||
}
|
||||
|
||||
private func togglePause() {
|
||||
withAnimation(.smooth) {
|
||||
if timerManager.isPaused {
|
||||
timerManager.resumeTimer()
|
||||
} else {
|
||||
timerManager.pauseTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
withAnimation(.smooth) {
|
||||
timerManager.stopTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private var progressTint: Color {
|
||||
switch colorMode {
|
||||
case .adaptive:
|
||||
return activePresetColor ?? timerManager.timerColor
|
||||
case .solid:
|
||||
return solidColor
|
||||
}
|
||||
}
|
||||
|
||||
private var activePresetColor: Color? {
|
||||
guard let presetId = timerManager.activePresetId else { return nil }
|
||||
return timerPresets.first { $0.id == presetId }?.color
|
||||
}
|
||||
}
|
||||
|
||||
private struct CustomTimerSection: View {
|
||||
@Binding var hours: Int
|
||||
@Binding var minutes: Int
|
||||
@Binding var seconds: Int
|
||||
let startAction: () -> Void
|
||||
|
||||
private var totalSeconds: Int {
|
||||
hours * 3600 + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Custom Timer")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
DurationStepper(title: "Hours", value: $hours, range: 0...23)
|
||||
DurationStepper(title: "Minutes", value: $minutes, range: 0...59)
|
||||
DurationStepper(title: "Seconds", value: $seconds, range: 0...59)
|
||||
}
|
||||
}
|
||||
|
||||
Text(formattedDuration)
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button(action: startAction) {
|
||||
Label("Start Custom Timer", systemImage: "play.fill")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(totalSeconds == 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(Color.white.opacity(0.05))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
|
||||
private var formattedDuration: String {
|
||||
let components = TimerPreset.DurationComponents(hours: hours, minutes: minutes, seconds: seconds)
|
||||
let interval = TimerPreset.duration(from: components)
|
||||
return TimerPreset(name: "", duration: interval, color: .clear).formattedDuration
|
||||
}
|
||||
}
|
||||
|
||||
private struct DurationStepper: View {
|
||||
let title: String
|
||||
@Binding var value: Int
|
||||
let range: ClosedRange<Int>
|
||||
|
||||
var body: some View {
|
||||
Stepper(value: $value, in: range) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
Spacer()
|
||||
Text("\(value)")
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
}
|
||||
}
|
||||
.frame(width: 88)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PresetList: View {
|
||||
let presets: [TimerPreset]
|
||||
let activePresetId: UUID?
|
||||
let startAction: (TimerPreset) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Presets")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.padding(.leading, 4)
|
||||
|
||||
if presets.isEmpty {
|
||||
Text("Configure presets in Settings")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.white.opacity(0.05))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(presets) { preset in
|
||||
TimerPresetRow(preset: preset, isActive: activePresetId == preset.id) {
|
||||
startAction(preset)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimerPresetRow: View {
|
||||
let preset: TimerPreset
|
||||
let isActive: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(preset.color.gradient)
|
||||
.frame(width: 20, height: 20)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(preset.name)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Text(preset.formattedDuration)
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: isActive ? "checkmark" : "play.fill")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(isActive ? preset.color : Color.secondary)
|
||||
.padding(6)
|
||||
.background(isActive ? preset.color.opacity(0.2) : Color.clear)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(isActive ? preset.color.opacity(0.18) : Color.white.opacity(0.05))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TimerPopover()
|
||||
.frame(width: 300)
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
}
|
||||
|
|
@ -77,3 +77,51 @@ enum LockScreenGlassStyle: String, CaseIterable, Defaults.Serializable, Identifi
|
|||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
enum LockScreenWeatherWidgetStyle: String, CaseIterable, Defaults.Serializable, Identifiable {
|
||||
case inline = "Inline"
|
||||
case circular = "Circular"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
enum LockScreenWeatherProviderSource: String, CaseIterable, Defaults.Serializable, Identifiable {
|
||||
case wttr = "wttr.in"
|
||||
case openMeteo = "Open Meteo"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String { rawValue }
|
||||
|
||||
var supportsAirQuality: Bool {
|
||||
switch self {
|
||||
case .wttr:
|
||||
return false
|
||||
case .openMeteo:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LockScreenWeatherTemperatureUnit: String, CaseIterable, Defaults.Serializable, Identifiable {
|
||||
case celsius = "Celsius"
|
||||
case fahrenheit = "Fahrenheit"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var usesMetricSystem: Bool { self == .celsius }
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .celsius: return "°C"
|
||||
case .fahrenheit: return "°F"
|
||||
}
|
||||
}
|
||||
|
||||
var openMeteoTemperatureParameter: String? {
|
||||
switch self {
|
||||
case .celsius: return nil
|
||||
case .fahrenheit: return "fahrenheit"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
245
DynamicIsland/managers/AudioRouteManager.swift
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import Combine
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
struct AudioOutputDevice: Identifiable, Equatable {
|
||||
let id: AudioDeviceID
|
||||
let name: String
|
||||
let transportType: UInt32
|
||||
|
||||
var iconName: String {
|
||||
let normalizedName = name.lowercased()
|
||||
|
||||
if normalizedName.contains("airpods") {
|
||||
return "airpodspro"
|
||||
}
|
||||
|
||||
if normalizedName.contains("macbook") {
|
||||
return "laptopcomputer"
|
||||
}
|
||||
|
||||
if normalizedName.contains("headphone") || normalizedName.contains("headset") {
|
||||
return "headphones"
|
||||
}
|
||||
|
||||
if normalizedName.contains("beats") {
|
||||
return "headphones"
|
||||
}
|
||||
|
||||
if normalizedName.contains("homepod") {
|
||||
return "hifispeaker.2"
|
||||
}
|
||||
|
||||
switch transportType {
|
||||
case kAudioDeviceTransportTypeBluetooth:
|
||||
if normalizedName.contains("speaker") {
|
||||
return "speaker.wave.2"
|
||||
}
|
||||
return "headphones"
|
||||
case kAudioDeviceTransportTypeAirPlay:
|
||||
return "airplayaudio"
|
||||
case kAudioDeviceTransportTypeDisplayPort, kAudioDeviceTransportTypeHDMI:
|
||||
return "tv"
|
||||
case kAudioDeviceTransportTypeUSB, kAudioDeviceTransportTypeFireWire:
|
||||
return "hifispeaker.2"
|
||||
case kAudioDeviceTransportTypePCI, kAudioDeviceTransportTypeVirtual:
|
||||
return "speaker.wave.2"
|
||||
case kAudioDeviceTransportTypeBuiltIn:
|
||||
return normalizedName.contains("display") ? "tv" : "speaker.wave.2"
|
||||
default:
|
||||
return "speaker.wave.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class AudioRouteManager: ObservableObject {
|
||||
static let shared = AudioRouteManager()
|
||||
|
||||
@Published private(set) var devices: [AudioOutputDevice] = []
|
||||
@Published private(set) var activeDeviceID: AudioDeviceID = 0
|
||||
|
||||
private let queue = DispatchQueue(label: "com.dynamicisland.audio-route", qos: .userInitiated)
|
||||
|
||||
private init() {
|
||||
refreshDevices()
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleRouteChange),
|
||||
name: .systemAudioRouteDidChange,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
var activeDevice: AudioOutputDevice? {
|
||||
devices.first { $0.id == activeDeviceID }
|
||||
}
|
||||
|
||||
func refreshDevices() {
|
||||
queue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let defaultID = self.fetchDefaultOutputDevice()
|
||||
let deviceInfos = self.fetchOutputDeviceIDs().compactMap(self.makeDeviceInfo)
|
||||
let sortedDevices = deviceInfos.sorted { lhs, rhs in
|
||||
if lhs.id == defaultID { return true }
|
||||
if rhs.id == defaultID { return false }
|
||||
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.activeDeviceID = defaultID
|
||||
self.devices = sortedDevices
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func select(device: AudioOutputDevice) {
|
||||
queue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.setDefaultOutputDevice(device.id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@objc private func handleRouteChange() {
|
||||
refreshDevices()
|
||||
}
|
||||
|
||||
private func fetchOutputDeviceIDs() -> [AudioDeviceID] {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
var dataSize: UInt32 = 0
|
||||
guard AudioObjectGetPropertyDataSize(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
&dataSize
|
||||
) == noErr else {
|
||||
return []
|
||||
}
|
||||
|
||||
let deviceCount = Int(dataSize) / MemoryLayout<AudioDeviceID>.size
|
||||
var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)
|
||||
let status = AudioObjectGetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
&dataSize,
|
||||
&deviceIDs
|
||||
)
|
||||
if status != noErr {
|
||||
return []
|
||||
}
|
||||
return deviceIDs
|
||||
}
|
||||
|
||||
private func makeDeviceInfo(for deviceID: AudioDeviceID) -> AudioOutputDevice? {
|
||||
guard deviceHasOutputChannels(deviceID) else { return nil }
|
||||
let name = deviceName(for: deviceID) ?? "Unknown Device"
|
||||
let transport = transportType(for: deviceID)
|
||||
return AudioOutputDevice(id: deviceID, name: name, transportType: transport)
|
||||
}
|
||||
|
||||
private func deviceHasOutputChannels(_ deviceID: AudioDeviceID) -> Bool {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||
mScope: kAudioDevicePropertyScopeOutput,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
var dataSize: UInt32 = 0
|
||||
guard AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &dataSize) == noErr else {
|
||||
return false
|
||||
}
|
||||
|
||||
let buffer = UnsafeMutableRawPointer.allocate(byteCount: Int(dataSize), alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||
defer { buffer.deallocate() }
|
||||
|
||||
guard AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, buffer) == noErr else {
|
||||
return false
|
||||
}
|
||||
|
||||
let audioBufferListPointer = buffer.assumingMemoryBound(to: AudioBufferList.self)
|
||||
let audioBuffers = UnsafeMutableAudioBufferListPointer(audioBufferListPointer)
|
||||
let channelCount = audioBuffers.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||
return channelCount > 0
|
||||
}
|
||||
|
||||
private func deviceName(for deviceID: AudioDeviceID) -> String? {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioObjectPropertyName,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
var name: CFString = "" as CFString
|
||||
var dataSize = UInt32(MemoryLayout<CFString?>.size)
|
||||
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, &name)
|
||||
guard status == noErr else { return nil }
|
||||
return name as String
|
||||
}
|
||||
|
||||
private func transportType(for deviceID: AudioDeviceID) -> UInt32 {
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyTransportType,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
|
||||
var type: UInt32 = kAudioDeviceTransportTypeUnknown
|
||||
var dataSize = UInt32(MemoryLayout<UInt32>.size)
|
||||
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &dataSize, &type)
|
||||
return status == noErr ? type : kAudioDeviceTransportTypeUnknown
|
||||
}
|
||||
|
||||
private func fetchDefaultOutputDevice() -> AudioDeviceID {
|
||||
var deviceID = AudioDeviceID()
|
||||
var dataSize = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
let status = AudioObjectGetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
&dataSize,
|
||||
&deviceID
|
||||
)
|
||||
return status == noErr ? deviceID : 0
|
||||
}
|
||||
|
||||
private func setDefaultOutputDevice(_ deviceID: AudioDeviceID) {
|
||||
var target = deviceID
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain
|
||||
)
|
||||
let status = AudioObjectSetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
UInt32(MemoryLayout<AudioDeviceID>.size),
|
||||
&target
|
||||
)
|
||||
if status == noErr {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.activeDeviceID = deviceID
|
||||
}
|
||||
refreshDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import AppKit
|
|||
import Defaults
|
||||
import SwiftUI
|
||||
import IOBluetooth
|
||||
import IOKit
|
||||
|
||||
/// Manages detection and monitoring of Bluetooth audio device connections
|
||||
class BluetoothAudioManager: ObservableObject {
|
||||
|
|
@ -27,6 +28,15 @@ class BluetoothAudioManager: ObservableObject {
|
|||
private var cancellables = Set<AnyCancellable>()
|
||||
private let coordinator = DynamicIslandViewCoordinator.shared
|
||||
private var pollingTimer: Timer?
|
||||
private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth"
|
||||
|
||||
@Published private(set) var batteryStatus: [String: String] = [:]
|
||||
|
||||
private var batteryStatusByAddress: [String: Int] = [:]
|
||||
private var batteryStatusByName: [String: Int] = [:]
|
||||
private var missingBatteryLog: Set<String> = []
|
||||
private var lastBatteryStatusUpdate: Date?
|
||||
private let batteryStatusUpdateInterval: TimeInterval = 20
|
||||
|
||||
// MARK: - Initialization
|
||||
private init() {
|
||||
|
|
@ -145,6 +155,8 @@ class BluetoothAudioManager: ObservableObject {
|
|||
// Update connection state
|
||||
isBluetoothAudioConnected = !connectedDevices.isEmpty
|
||||
|
||||
refreshBatteryLevelsForConnectedDevices()
|
||||
|
||||
if let lastDevice = connectedDevices.last {
|
||||
lastConnectedDevice = lastDevice
|
||||
print("🎧 [BluetoothAudioManager] ✅ Bluetooth audio connected: \(lastDevice.name)")
|
||||
|
|
@ -202,11 +214,17 @@ class BluetoothAudioManager: ObservableObject {
|
|||
lastConnectedDevice = audioDevice
|
||||
isBluetoothAudioConnected = true
|
||||
|
||||
refreshBatteryLevelsForConnectedDevices()
|
||||
|
||||
// Show HUD for new connection
|
||||
if let refreshedDevice = connectedDevices.last {
|
||||
showDeviceConnectedHUD(refreshedDevice)
|
||||
} else {
|
||||
showDeviceConnectedHUD(audioDevice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the list of connected devices (for disconnections)
|
||||
private func updateConnectedDevices() {
|
||||
|
|
@ -229,6 +247,8 @@ class BluetoothAudioManager: ObservableObject {
|
|||
}
|
||||
|
||||
isBluetoothAudioConnected = !connectedDevices.isEmpty
|
||||
|
||||
refreshBatteryLevelsForConnectedDevices()
|
||||
}
|
||||
|
||||
/// Handles Bluetooth device connection event (legacy - kept for compatibility)
|
||||
|
|
@ -326,11 +346,29 @@ class BluetoothAudioManager: ObservableObject {
|
|||
|
||||
/// Extracts battery level from Bluetooth device
|
||||
private func getBatteryLevel(from device: IOBluetoothDevice) -> Int? {
|
||||
// Try to read battery level from device
|
||||
// Note: This may require additional permissions or may not be available for all devices
|
||||
updateBatteryStatuses()
|
||||
|
||||
// Placeholder: Return nil if not available
|
||||
// TODO: Implement actual battery level reading if device supports it
|
||||
if let level = batteryLevelFromRegistry(forAddress: device.addressString) {
|
||||
clearMissingBatteryInfo(for: device)
|
||||
return level
|
||||
}
|
||||
|
||||
if let name = device.name, let level = batteryLevelFromRegistry(forName: name) {
|
||||
clearMissingBatteryInfo(for: device)
|
||||
return level
|
||||
}
|
||||
|
||||
if let level = batteryLevelFromDefaults(forAddress: device.addressString) {
|
||||
clearMissingBatteryInfo(for: device)
|
||||
return level
|
||||
}
|
||||
|
||||
if let name = device.name, let level = batteryLevelFromDefaults(forName: name) {
|
||||
clearMissingBatteryInfo(for: device)
|
||||
return level
|
||||
}
|
||||
|
||||
logMissingBatteryInfo(for: device)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -373,6 +411,590 @@ class BluetoothAudioManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private func refreshBatteryLevelsForConnectedDevices() {
|
||||
updateBatteryStatuses(force: true)
|
||||
|
||||
var updatedDevices: [BluetoothAudioDevice] = []
|
||||
for device in connectedDevices {
|
||||
let refreshedLevel =
|
||||
batteryLevelFromRegistry(forAddress: device.address)
|
||||
?? batteryLevelFromRegistry(forName: device.name)
|
||||
?? batteryLevelFromDefaults(forAddress: device.address)
|
||||
?? batteryLevelFromDefaults(forName: device.name)
|
||||
?? device.batteryLevel
|
||||
let updatedDevice = device.withBatteryLevel(refreshedLevel)
|
||||
updatedDevices.append(updatedDevice)
|
||||
|
||||
if let refreshedLevel {
|
||||
clearMissingBatteryInfo(forName: device.name, address: device.address)
|
||||
} else {
|
||||
logMissingBatteryInfo(forName: device.name, address: device.address)
|
||||
}
|
||||
}
|
||||
|
||||
connectedDevices = updatedDevices
|
||||
if let last = connectedDevices.last {
|
||||
lastConnectedDevice = last
|
||||
}
|
||||
}
|
||||
|
||||
private func batteryLevelFromDefaults(forAddress address: String?) -> Int? {
|
||||
guard let address, !address.isEmpty else { return nil }
|
||||
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
|
||||
guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil }
|
||||
|
||||
let normalizedTarget = normalizeBluetoothIdentifier(address)
|
||||
var bestMatch: Int?
|
||||
|
||||
for (key, value) in deviceCache {
|
||||
guard let payload = value as? [String: Any] else { continue }
|
||||
if matchesBluetoothIdentifier(normalizedTarget, key: key, payload: payload) {
|
||||
if let level = extractBatteryPercentage(from: payload) {
|
||||
let clamped = clampBatteryPercentage(level)
|
||||
bestMatch = max(bestMatch ?? clamped, clamped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
private func batteryLevelFromDefaults(forName name: String) -> Int? {
|
||||
guard !name.isEmpty else { return nil }
|
||||
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
|
||||
guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil }
|
||||
|
||||
var bestMatch: Int?
|
||||
|
||||
for value in deviceCache.values {
|
||||
guard let payload = value as? [String: Any] else { continue }
|
||||
let candidateName = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String)
|
||||
if let candidateName, candidateName.caseInsensitiveCompare(name) == .orderedSame {
|
||||
if let level = extractBatteryPercentage(from: payload) {
|
||||
let clamped = clampBatteryPercentage(level)
|
||||
bestMatch = max(bestMatch ?? clamped, clamped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
private func batteryLevelFromRegistry(forName name: String) -> Int? {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let normalized = normalizeProductName(trimmed)
|
||||
guard !normalized.isEmpty else { return nil }
|
||||
if let value = batteryStatusByName[normalized] {
|
||||
return clampBatteryPercentage(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func updateBatteryStatuses(force: Bool = false) {
|
||||
let now = Date()
|
||||
if !force, let lastBatteryStatusUpdate,
|
||||
now.timeIntervalSince(lastBatteryStatusUpdate) < batteryStatusUpdateInterval {
|
||||
return
|
||||
}
|
||||
|
||||
var combinedAddressPercentages: [String: Int] = [:]
|
||||
var combinedNamePercentages: [String: Int] = [:]
|
||||
|
||||
let registry = collectRegistryBatteryLevels()
|
||||
mergeBatteryLevels(into: &combinedAddressPercentages, from: registry.addresses)
|
||||
mergeBatteryLevels(into: &combinedNamePercentages, from: registry.names)
|
||||
|
||||
let defaults = collectDefaultsBatteryLevels()
|
||||
mergeBatteryLevels(into: &combinedAddressPercentages, from: defaults.addresses)
|
||||
mergeBatteryLevels(into: &combinedNamePercentages, from: defaults.names)
|
||||
|
||||
let profiler = collectSystemProfilerBatteryLevels()
|
||||
mergeBatteryLevels(into: &combinedAddressPercentages, from: profiler.addresses)
|
||||
mergeBatteryLevels(into: &combinedNamePercentages, from: profiler.names)
|
||||
|
||||
var statuses: [String: String] = [:]
|
||||
for (key, value) in combinedAddressPercentages {
|
||||
statuses[key] = String(clampBatteryPercentage(value))
|
||||
}
|
||||
|
||||
let applyUpdates = {
|
||||
self.batteryStatus = statuses
|
||||
self.batteryStatusByAddress = combinedAddressPercentages
|
||||
self.batteryStatusByName = combinedNamePercentages
|
||||
self.lastBatteryStatusUpdate = now
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
applyUpdates()
|
||||
} else {
|
||||
DispatchQueue.main.sync(execute: applyUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
private func mergeBatteryLevels(into target: inout [String: Int], from source: [String: Int]) {
|
||||
guard !source.isEmpty else { return }
|
||||
for (key, value) in source {
|
||||
guard !key.isEmpty else { continue }
|
||||
if let existing = target[key] {
|
||||
target[key] = max(existing, value)
|
||||
} else {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func collectRegistryBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
|
||||
var addressPercentages: [String: Int] = [:]
|
||||
var namePercentages: [String: Int] = [:]
|
||||
|
||||
var iterator = io_iterator_t()
|
||||
let matchingDict: CFDictionary = IOServiceMatching("AppleDeviceManagementHIDEventService")
|
||||
|
||||
let servicePort: mach_port_t
|
||||
if #available(macOS 12.0, *) {
|
||||
servicePort = kIOMainPortDefault
|
||||
} else {
|
||||
servicePort = kIOMasterPortDefault
|
||||
}
|
||||
|
||||
let kernResult = IOServiceGetMatchingServices(servicePort, matchingDict, &iterator)
|
||||
|
||||
if kernResult == KERN_SUCCESS {
|
||||
var entry: io_object_t = IOIteratorNext(iterator)
|
||||
while entry != 0 {
|
||||
if let percent = IORegistryEntryCreateCFProperty(entry, "BatteryPercent" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? Int {
|
||||
let normalizedPercent = clampBatteryPercentage(percent)
|
||||
|
||||
let identifierKeys = ["DeviceAddress", "SerialNumber", "BD_ADDR"]
|
||||
for key in identifierKeys {
|
||||
if let identifier = stringValue(forKey: key, entry: entry) {
|
||||
let normalizedIdentifier = normalizeBluetoothIdentifier(identifier)
|
||||
if !normalizedIdentifier.isEmpty {
|
||||
if let existing = addressPercentages[normalizedIdentifier] {
|
||||
addressPercentages[normalizedIdentifier] = max(existing, normalizedPercent)
|
||||
} else {
|
||||
addressPercentages[normalizedIdentifier] = normalizedPercent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let nameKeys = [
|
||||
"Product",
|
||||
"ProductName",
|
||||
"DeviceName",
|
||||
"Name",
|
||||
"USB Product Name",
|
||||
"Bluetooth Product Name"
|
||||
]
|
||||
|
||||
for key in nameKeys {
|
||||
if let product = stringValue(forKey: key, entry: entry) {
|
||||
let normalizedName = normalizeProductName(product)
|
||||
if !normalizedName.isEmpty {
|
||||
if let existing = namePercentages[normalizedName] {
|
||||
namePercentages[normalizedName] = max(existing, normalizedPercent)
|
||||
} else {
|
||||
namePercentages[normalizedName] = normalizedPercent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IOObjectRelease(entry)
|
||||
entry = IOIteratorNext(iterator)
|
||||
}
|
||||
}
|
||||
|
||||
IOObjectRelease(iterator)
|
||||
|
||||
return (addressPercentages, namePercentages)
|
||||
}
|
||||
|
||||
private func collectDefaultsBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
|
||||
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite),
|
||||
let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else {
|
||||
return ([:], [:])
|
||||
}
|
||||
|
||||
var addressPercentages: [String: Int] = [:]
|
||||
var namePercentages: [String: Int] = [:]
|
||||
|
||||
for (key, value) in deviceCache {
|
||||
guard let payload = value as? [String: Any] else { continue }
|
||||
guard let level = extractBatteryPercentage(from: payload) else { continue }
|
||||
let clamped = clampBatteryPercentage(level)
|
||||
|
||||
let normalizedKey = normalizeBluetoothIdentifier(key)
|
||||
if !normalizedKey.isEmpty {
|
||||
addressPercentages[normalizedKey] = max(addressPercentages[normalizedKey] ?? clamped, clamped)
|
||||
}
|
||||
|
||||
for identifier in identifiersFromDeviceCachePayload(payload) {
|
||||
addressPercentages[identifier] = max(addressPercentages[identifier] ?? clamped, clamped)
|
||||
}
|
||||
|
||||
if let name = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String) {
|
||||
let normalizedName = normalizeProductName(name)
|
||||
if !normalizedName.isEmpty {
|
||||
namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (addressPercentages, namePercentages)
|
||||
}
|
||||
|
||||
private func collectSystemProfilerBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
|
||||
guard let root = systemProfilerBluetoothDictionary() else {
|
||||
return ([:], [:])
|
||||
}
|
||||
|
||||
var addressPercentages: [String: Int] = [:]
|
||||
var namePercentages: [String: Int] = [:]
|
||||
|
||||
if let connectedList = root["device_connected"] as? [[String: [String: Any]]] {
|
||||
for deviceGroup in connectedList {
|
||||
for (rawName, payload) in deviceGroup {
|
||||
guard let percent = extractSystemProfilerBatteryPercentage(from: payload) else { continue }
|
||||
let clamped = clampBatteryPercentage(percent)
|
||||
|
||||
let normalizedName = normalizeProductName(rawName)
|
||||
if !normalizedName.isEmpty {
|
||||
namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped)
|
||||
}
|
||||
|
||||
for address in profilerAddressCandidates(from: payload) {
|
||||
let normalizedAddress = normalizeBluetoothIdentifier(address)
|
||||
if !normalizedAddress.isEmpty {
|
||||
addressPercentages[normalizedAddress] = max(addressPercentages[normalizedAddress] ?? clamped, clamped)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (addressPercentages, namePercentages)
|
||||
}
|
||||
|
||||
private func identifiersFromDeviceCachePayload(_ payload: [String: Any]) -> [String] {
|
||||
var identifiers: Set<String> = []
|
||||
let candidateKeys = ["DeviceAddress", "Address", "BD_ADDR", "SerialNumber"]
|
||||
|
||||
for key in candidateKeys {
|
||||
if let value = payload[key] as? String {
|
||||
let normalized = normalizeBluetoothIdentifier(value)
|
||||
if !normalized.isEmpty {
|
||||
identifiers.insert(normalized)
|
||||
}
|
||||
} else if let data = payload[key] as? Data,
|
||||
let ascii = String(data: data, encoding: .utf8) {
|
||||
let normalized = normalizeBluetoothIdentifier(ascii)
|
||||
if !normalized.isEmpty {
|
||||
identifiers.insert(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array(identifiers)
|
||||
}
|
||||
|
||||
private func systemProfilerBluetoothDictionary() -> [String: Any]? {
|
||||
let process = Process()
|
||||
process.launchPath = "/usr/sbin/system_profiler"
|
||||
process.arguments = ["SPBluetoothDataType", "-json"]
|
||||
|
||||
let outputPipe = Pipe()
|
||||
process.standardOutput = outputPipe
|
||||
let errorPipe = Pipe()
|
||||
process.standardError = errorPipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
|
||||
guard process.terminationStatus == 0 else {
|
||||
return nil
|
||||
}
|
||||
guard !data.isEmpty else { return nil }
|
||||
|
||||
guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
||||
let entries = jsonObject["SPBluetoothDataType"] as? [[String: Any]],
|
||||
let root = entries.first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
private func extractSystemProfilerBatteryPercentage(from payload: [String: Any]) -> Int? {
|
||||
let preferredKeys = [
|
||||
"device_batteryLevelCase",
|
||||
"device_batteryLevelLeft",
|
||||
"device_batteryLevelRight",
|
||||
"device_batteryLevelMain",
|
||||
"device_batteryLevel",
|
||||
"device_batteryLevelCombined",
|
||||
"device_batteryPercentCombined",
|
||||
"Left Battery Level",
|
||||
"Right Battery Level",
|
||||
"Battery Level",
|
||||
"BatteryPercent"
|
||||
]
|
||||
|
||||
var values: [Int] = []
|
||||
|
||||
for key in preferredKeys {
|
||||
if let raw = payload[key], let converted = convertToBatteryPercentage(raw) {
|
||||
values.append(converted)
|
||||
}
|
||||
}
|
||||
|
||||
if values.isEmpty {
|
||||
for (key, raw) in payload where key.lowercased().contains("battery") {
|
||||
if let converted = convertToBatteryPercentage(raw) {
|
||||
values.append(converted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let validValues = values.filter { $0 >= 0 }
|
||||
return validValues.max()
|
||||
}
|
||||
|
||||
private func profilerAddressCandidates(from payload: [String: Any]) -> [String] {
|
||||
var addresses: Set<String> = []
|
||||
let keys = [
|
||||
"device_address",
|
||||
"device_mac_address",
|
||||
"device_serial_num",
|
||||
"device_serialNumber",
|
||||
"device_serial_number"
|
||||
]
|
||||
|
||||
for key in keys {
|
||||
if let value = payload[key] as? String, !value.isEmpty {
|
||||
addresses.insert(value)
|
||||
} else if let data = payload[key] as? Data,
|
||||
let ascii = String(data: data, encoding: .utf8), !ascii.isEmpty {
|
||||
addresses.insert(ascii)
|
||||
}
|
||||
}
|
||||
|
||||
return Array(addresses)
|
||||
}
|
||||
|
||||
private func batteryLevelFromRegistry(forAddress address: String?) -> Int? {
|
||||
guard let address, !address.isEmpty else { return nil }
|
||||
let normalized = normalizeBluetoothIdentifier(address)
|
||||
if let value = batteryStatusByAddress[normalized] {
|
||||
return clampBatteryPercentage(value)
|
||||
}
|
||||
if let storedValue = batteryStatus[normalized], let value = Int(storedValue) {
|
||||
return clampBatteryPercentage(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func extractBatteryPercentage(from payload: [String: Any]) -> Int? {
|
||||
let keys = [
|
||||
"BatteryPercent",
|
||||
"BatteryPercentCase",
|
||||
"BatteryPercentLeft",
|
||||
"BatteryPercentRight",
|
||||
"BatteryPercentSingle",
|
||||
"BatteryPercentCombined",
|
||||
"BatteryPercentMain",
|
||||
"device_batteryLevelLeft",
|
||||
"device_batteryLevelRight",
|
||||
"device_batteryLevelMain",
|
||||
"Left Battery Level",
|
||||
"Right Battery Level"
|
||||
]
|
||||
|
||||
var values: [Int] = []
|
||||
|
||||
for key in keys {
|
||||
guard let raw = payload[key] else { continue }
|
||||
if let converted = convertToBatteryPercentage(raw) {
|
||||
values.append(converted)
|
||||
}
|
||||
}
|
||||
|
||||
if values.isEmpty,
|
||||
let services = payload["Services"] as? [[String: Any]] {
|
||||
for service in services {
|
||||
if let serviceValues = service["BatteryPercentages"] as? [String: Any] {
|
||||
for value in serviceValues.values {
|
||||
if let converted = convertToBatteryPercentage(value) {
|
||||
values.append(converted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values.max()
|
||||
}
|
||||
|
||||
private func convertToBatteryPercentage(_ value: Any) -> Int? {
|
||||
if let number = value as? Int {
|
||||
if number == 1 {
|
||||
return 100
|
||||
}
|
||||
return number
|
||||
}
|
||||
if let number = value as? Double {
|
||||
if number <= 1.0 {
|
||||
return Int(number * 100)
|
||||
}
|
||||
return Int(number)
|
||||
}
|
||||
if let string = value as? String {
|
||||
let trimmed = string.replacingOccurrences(of: "%", with: "")
|
||||
if let doubleValue = Double(trimmed) {
|
||||
if doubleValue <= 1.0 {
|
||||
return Int(doubleValue * 100)
|
||||
}
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func clampBatteryPercentage(_ value: Int) -> Int {
|
||||
min(max(value, 0), 100)
|
||||
}
|
||||
|
||||
private func matchesBluetoothIdentifier(_ normalizedTarget: String, key: String, payload: [String: Any]) -> Bool {
|
||||
if normalizeBluetoothIdentifier(key) == normalizedTarget {
|
||||
return true
|
||||
}
|
||||
|
||||
let candidateFields: [String?] = [
|
||||
payload["DeviceAddress"] as? String,
|
||||
payload["Address"] as? String,
|
||||
payload["BD_ADDR"] as? String,
|
||||
payload["SerialNumber"] as? String
|
||||
]
|
||||
|
||||
for field in candidateFields {
|
||||
if let field, normalizeBluetoothIdentifier(field) == normalizedTarget {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if let deviceAddressData = payload["DeviceAddress"] as? Data,
|
||||
let ascii = String(data: deviceAddressData, encoding: .utf8),
|
||||
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
|
||||
return true
|
||||
}
|
||||
|
||||
if let addressData = payload["BD_ADDR"] as? Data,
|
||||
let ascii = String(data: addressData, encoding: .utf8),
|
||||
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
|
||||
return true
|
||||
}
|
||||
|
||||
if let serialData = payload["SerialNumber"] as? Data,
|
||||
let ascii = String(data: serialData, encoding: .utf8),
|
||||
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func logMissingBatteryInfo(for device: IOBluetoothDevice) {
|
||||
let name = device.name ?? ""
|
||||
let address = device.addressString ?? ""
|
||||
logMissingBatteryInfo(forName: name, address: address)
|
||||
}
|
||||
|
||||
private func clearMissingBatteryInfo(for device: IOBluetoothDevice) {
|
||||
let name = device.name ?? ""
|
||||
let address = device.addressString ?? ""
|
||||
clearMissingBatteryInfo(forName: name, address: address)
|
||||
}
|
||||
|
||||
private func logMissingBatteryInfo(forName name: String, address: String) {
|
||||
let key = missingBatteryKey(name: name, address: address)
|
||||
guard !missingBatteryLog.contains(key) else { return }
|
||||
missingBatteryLog.insert(key)
|
||||
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let displayName = trimmedName.isEmpty ? "unknown device" : trimmedName
|
||||
let isUnknownAddress = trimmedAddress.caseInsensitiveCompare("unknown") == .orderedSame
|
||||
let displayAddress = (trimmedAddress.isEmpty || isUnknownAddress) ? "N/A" : trimmedAddress
|
||||
print("🎧 [BluetoothAudioManager] ⚠️ Battery percentage unavailable for \(displayName) (\(displayAddress))")
|
||||
}
|
||||
|
||||
private func clearMissingBatteryInfo(forName name: String, address: String) {
|
||||
let key = missingBatteryKey(name: name, address: address)
|
||||
missingBatteryLog.remove(key)
|
||||
}
|
||||
|
||||
private func missingBatteryKey(name: String, address: String) -> String {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let normalizedName = normalizeProductName(trimmedName)
|
||||
|
||||
let isUnknownAddress = trimmedAddress.caseInsensitiveCompare("unknown") == .orderedSame
|
||||
let normalizedAddress = (trimmedAddress.isEmpty || isUnknownAddress) ? "" : normalizeBluetoothIdentifier(trimmedAddress)
|
||||
|
||||
if normalizedName.isEmpty && normalizedAddress.isEmpty {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
return normalizedName + "#" + normalizedAddress
|
||||
}
|
||||
|
||||
private func stringValue(forKey key: String, entry: io_object_t) -> String? {
|
||||
guard let unmanaged = IORegistryEntryCreateCFProperty(entry, key as CFString, kCFAllocatorDefault, 0) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let value = unmanaged.takeRetainedValue()
|
||||
|
||||
if let string = value as? String, !string.isEmpty {
|
||||
return string
|
||||
}
|
||||
|
||||
if let data = value as? Data, let ascii = String(data: data, encoding: .utf8), !ascii.isEmpty {
|
||||
return ascii
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func normalizeBluetoothIdentifier(_ value: String) -> String {
|
||||
return value
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: ":", with: "")
|
||||
.replacingOccurrences(of: "-", with: "")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
}
|
||||
|
||||
private func normalizeProductName(_ name: String) -> String {
|
||||
let components = name
|
||||
.lowercased()
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
return components.joined()
|
||||
}
|
||||
|
||||
// MARK: - HUD Display
|
||||
|
||||
/// Shows HUD notification for newly connected audio device
|
||||
|
|
@ -382,12 +1004,20 @@ class BluetoothAudioManager: ObservableObject {
|
|||
print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD")
|
||||
|
||||
// Convert battery percentage to 0.0-1.0 value
|
||||
let batteryValue: CGFloat = if let battery = device.batteryLevel {
|
||||
CGFloat(battery) / 100.0
|
||||
let reportedBattery = device.batteryLevel
|
||||
?? batteryLevelFromRegistry(forAddress: device.address)
|
||||
?? batteryLevelFromRegistry(forName: device.name)
|
||||
?? batteryLevelFromDefaults(forAddress: device.address)
|
||||
?? batteryLevelFromDefaults(forName: device.name)
|
||||
|
||||
let batteryValue: CGFloat = if let battery = reportedBattery {
|
||||
CGFloat(clampBatteryPercentage(battery)) / 100.0
|
||||
} else {
|
||||
0.0 // 0 means battery info not available
|
||||
}
|
||||
|
||||
HUDSuppressionCoordinator.shared.suppressVolumeHUD(for: 1.5)
|
||||
|
||||
// Show HUD via coordinator
|
||||
coordinator.toggleSneakPeek(
|
||||
status: true,
|
||||
|
|
@ -411,16 +1041,47 @@ class BluetoothAudioManager: ObservableObject {
|
|||
observers.removeAll()
|
||||
cancellables.removeAll()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func refreshConnectedDeviceBatteries() {
|
||||
refreshBatteryLevelsForConnectedDevices()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct BluetoothAudioDevice: Identifiable {
|
||||
let id = UUID()
|
||||
let id: UUID
|
||||
let name: String
|
||||
let address: String
|
||||
let batteryLevel: Int? // 0-100, nil if not available
|
||||
let deviceType: BluetoothAudioDeviceType
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
address: String,
|
||||
batteryLevel: Int?,
|
||||
deviceType: BluetoothAudioDeviceType
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.address = address
|
||||
self.batteryLevel = batteryLevel
|
||||
self.deviceType = deviceType
|
||||
}
|
||||
}
|
||||
|
||||
extension BluetoothAudioDevice {
|
||||
func withBatteryLevel(_ batteryLevel: Int?) -> BluetoothAudioDevice {
|
||||
BluetoothAudioDevice(
|
||||
id: id,
|
||||
name: name,
|
||||
address: address,
|
||||
batteryLevel: batteryLevel,
|
||||
deviceType: deviceType
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum BluetoothAudioDeviceType {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import SwiftUI
|
|||
import AppKit
|
||||
import SkyLightWindow
|
||||
import Defaults
|
||||
import QuartzCore
|
||||
|
||||
@MainActor
|
||||
class LockScreenPanelManager {
|
||||
|
|
@ -17,6 +18,8 @@ class LockScreenPanelManager {
|
|||
private var panelWindow: NSWindow?
|
||||
private var hasDelegated = false
|
||||
private var collapsedFrame: NSRect?
|
||||
private let collapsedPanelCornerRadius: CGFloat = 28
|
||||
private let expandedPanelCornerRadius: CGFloat = 52
|
||||
|
||||
private init() {
|
||||
print("[\(timestamp())] LockScreenPanelManager: initialized")
|
||||
|
|
@ -76,7 +79,16 @@ class LockScreenPanelManager {
|
|||
}
|
||||
|
||||
window.setFrame(targetFrame, display: true)
|
||||
window.contentView = NSHostingView(rootView: LockScreenMusicPanel())
|
||||
let hosting = NSHostingView(rootView: LockScreenMusicPanel())
|
||||
hosting.frame = NSRect(origin: .zero, size: targetFrame.size)
|
||||
window.contentView = hosting
|
||||
|
||||
// Ensure the underlying window content is clipped to rounded corners
|
||||
if let content = window.contentView {
|
||||
content.wantsLayer = true
|
||||
content.layer?.masksToBounds = true
|
||||
content.layer?.cornerRadius = collapsedPanelCornerRadius
|
||||
}
|
||||
|
||||
if !hasDelegated {
|
||||
SkyLightOperator.shared.delegateWindow(window)
|
||||
|
|
@ -89,21 +101,38 @@ class LockScreenPanelManager {
|
|||
print("[\(timestamp())] LockScreenPanelManager: panel visible")
|
||||
}
|
||||
|
||||
func updatePanelSize(expanded: Bool, animated: Bool = true) {
|
||||
func updatePanelSize(expanded: Bool, additionalHeight: CGFloat = 0, animated: Bool = true) {
|
||||
guard let window = panelWindow, let baseFrame = collapsedFrame else {
|
||||
return
|
||||
}
|
||||
|
||||
let targetSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize
|
||||
let originX = baseFrame.midX - (targetSize.width / 2)
|
||||
let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize
|
||||
let targetWidth = baseSize.width
|
||||
let targetHeight = baseSize.height + additionalHeight
|
||||
let originX = baseFrame.midX - (targetWidth / 2)
|
||||
let originY = baseFrame.origin.y
|
||||
let targetFrame = NSRect(x: originX, y: originY, width: targetSize.width, height: targetSize.height)
|
||||
let targetFrame = NSRect(x: originX, y: originY, width: targetWidth, height: targetHeight)
|
||||
|
||||
if animated {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.45
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
window.animator().setFrame(targetFrame, display: true)
|
||||
}
|
||||
} else {
|
||||
window.setFrame(targetFrame, display: true)
|
||||
}
|
||||
|
||||
// Update corner radius to match the SwiftUI panel's style
|
||||
let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius
|
||||
if animated {
|
||||
CATransaction.begin()
|
||||
CATransaction.setAnimationDuration(0.28)
|
||||
window.contentView?.layer?.cornerRadius = targetRadius
|
||||
CATransaction.commit()
|
||||
} else {
|
||||
window.contentView?.layer?.cornerRadius = targetRadius
|
||||
}
|
||||
}
|
||||
|
||||
func hidePanel() {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import Foundation
|
||||
import Defaults
|
||||
import CoreLocation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class LockScreenWeatherManager: ObservableObject {
|
||||
|
|
@ -11,8 +12,17 @@ final class LockScreenWeatherManager: ObservableObject {
|
|||
private let provider = LockScreenWeatherProvider()
|
||||
private let locationProvider = LockScreenWeatherLocationProvider()
|
||||
private var lastFetchDate: Date?
|
||||
private var latestWeatherPayload: LockScreenWeatherSnapshot?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private init() {}
|
||||
private init() {
|
||||
observeAccessoryChanges()
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.locationProvider.prepareAuthorization()
|
||||
_ = await self.refresh(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
func prepareLocationAccess() {
|
||||
locationProvider.prepareAuthorization()
|
||||
|
|
@ -28,9 +38,15 @@ final class LockScreenWeatherManager: ObservableObject {
|
|||
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refresh(force: self.snapshot == nil)
|
||||
if let snapshot = self.snapshot {
|
||||
LockScreenWeatherPanelManager.shared.show(with: snapshot)
|
||||
let snapshot = await self.refresh(force: self.snapshot == nil)
|
||||
|
||||
guard LockScreenManager.shared.currentLockStatus else {
|
||||
LockScreenWeatherPanelManager.shared.hide()
|
||||
return
|
||||
}
|
||||
|
||||
if let snapshot {
|
||||
self.deliver(snapshot, forceShow: true)
|
||||
} else {
|
||||
LockScreenWeatherPanelManager.shared.hide()
|
||||
}
|
||||
|
|
@ -41,32 +57,428 @@ final class LockScreenWeatherManager: ObservableObject {
|
|||
LockScreenWeatherPanelManager.shared.hide()
|
||||
}
|
||||
|
||||
func refresh(force: Bool = false) async {
|
||||
@discardableResult
|
||||
func refresh(force: Bool = false) async -> LockScreenWeatherSnapshot? {
|
||||
NSLog("LockScreenWeatherManager: refresh requested (force=%@)", force ? "true" : "false")
|
||||
let interval = Defaults[.lockScreenWeatherRefreshInterval]
|
||||
if !force, let lastFetchDate, Date().timeIntervalSince(lastFetchDate) < interval {
|
||||
if let snapshot = snapshot {
|
||||
LockScreenWeatherPanelManager.shared.update(with: snapshot)
|
||||
let remaining = interval - Date().timeIntervalSince(lastFetchDate)
|
||||
NSLog("LockScreenWeatherManager: skipping refresh (%.0f s remaining until next fetch)", max(remaining, 0))
|
||||
if let payload = latestWeatherPayload {
|
||||
if Defaults[.lockScreenWeatherShowsBluetooth] {
|
||||
BluetoothAudioManager.shared.refreshConnectedDeviceBatteries()
|
||||
}
|
||||
return
|
||||
let snapshot = makeSnapshot(from: payload)
|
||||
self.snapshot = snapshot
|
||||
deliver(snapshot, forceShow: false)
|
||||
return snapshot
|
||||
} else if let snapshot = snapshot {
|
||||
deliver(snapshot, forceShow: false)
|
||||
return snapshot
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
do {
|
||||
let location = await locationProvider.currentLocation()
|
||||
let snapshot = try await provider.fetchSnapshot(location: location)
|
||||
let provider = Defaults[.lockScreenWeatherProviderSource]
|
||||
NSLog(
|
||||
"LockScreenWeatherManager: fetching weather from %@ (location %@)",
|
||||
provider.displayName,
|
||||
location != nil ? "available" : "missing"
|
||||
)
|
||||
let payload = try await fetchWeatherPayload(location: location)
|
||||
latestWeatherPayload = payload
|
||||
if Defaults[.lockScreenWeatherShowsBluetooth] {
|
||||
BluetoothAudioManager.shared.refreshConnectedDeviceBatteries()
|
||||
}
|
||||
let snapshot = makeSnapshot(from: payload)
|
||||
self.snapshot = snapshot
|
||||
lastFetchDate = Date()
|
||||
LockScreenWeatherPanelManager.shared.update(with: snapshot)
|
||||
deliver(snapshot, forceShow: false)
|
||||
NSLog("LockScreenWeatherManager: weather refresh succeeded")
|
||||
return snapshot
|
||||
} catch {
|
||||
NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)")
|
||||
|
||||
let providerSource = Defaults[.lockScreenWeatherProviderSource]
|
||||
let showsCharging = Defaults[.lockScreenWeatherShowsCharging]
|
||||
let chargingInfo = showsCharging ? makeChargingInfo() : nil
|
||||
let showsBatteryGauge = Defaults[.lockScreenWeatherShowsBatteryGauge]
|
||||
let batteryInfo = showsBatteryGauge ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil) : nil
|
||||
let fallback = LockScreenWeatherSnapshot(
|
||||
temperatureText: snapshot?.temperatureText ?? "--",
|
||||
symbolName: snapshot?.symbolName ?? "cloud.fill",
|
||||
description: snapshot?.description ?? "",
|
||||
locationName: snapshot?.locationName,
|
||||
charging: chargingInfo,
|
||||
bluetooth: Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil,
|
||||
battery: batteryInfo,
|
||||
showsLocation: snapshot?.showsLocation ?? false,
|
||||
airQuality: (providerSource.supportsAirQuality && Defaults[.lockScreenWeatherShowsAQI]) ? snapshot?.airQuality : nil,
|
||||
widgetStyle: Defaults[.lockScreenWeatherWidgetStyle],
|
||||
showsChargingPercentage: Defaults[.lockScreenWeatherShowsChargingPercentage],
|
||||
temperatureInfo: snapshot?.temperatureInfo,
|
||||
usesGaugeTint: Defaults[.lockScreenWeatherUsesGaugeTint]
|
||||
)
|
||||
|
||||
self.snapshot = fallback
|
||||
deliver(fallback, forceShow: false)
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchWeatherPayload(location: CLLocation?) async throws -> LockScreenWeatherSnapshot {
|
||||
let primarySource = Defaults[.lockScreenWeatherProviderSource]
|
||||
|
||||
do {
|
||||
return try await provider.fetchSnapshot(location: location, source: primarySource)
|
||||
} catch {
|
||||
if primarySource == .openMeteo {
|
||||
NSLog("LockScreenWeatherManager: Open Meteo fetch failed - %@. Falling back to wttr.in", error.localizedDescription)
|
||||
do {
|
||||
return try await provider.fetchSnapshot(location: location, source: .wttr)
|
||||
} catch {
|
||||
NSLog("LockScreenWeatherManager: wttr.in fallback also failed - %@", error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func observeAccessoryChanges() {
|
||||
let bluetoothManager = BluetoothAudioManager.shared
|
||||
|
||||
bluetoothManager.$connectedDevices
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.handleAccessoryUpdate(triggerBluetoothRefresh: false)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
bluetoothManager.$lastConnectedDevice
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.handleAccessoryUpdate(triggerBluetoothRefresh: false)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
let battery = BatteryStatusViewModel.shared
|
||||
let batteryPublishers: [AnyPublisher<Void, Never>] = [
|
||||
battery.$isCharging.map { _ in () }.eraseToAnyPublisher(),
|
||||
battery.$isPluggedIn.map { _ in () }.eraseToAnyPublisher(),
|
||||
battery.$timeToFullCharge.map { _ in () }.eraseToAnyPublisher()
|
||||
]
|
||||
|
||||
Publishers.MergeMany(batteryPublishers)
|
||||
.debounce(for: .milliseconds(150), scheduler: RunLoop.main)
|
||||
.sink { [weak self] in
|
||||
self?.handleAccessoryUpdate(triggerBluetoothRefresh: false)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
let defaultsPublishers: [AnyPublisher<Void, Never>] = [
|
||||
Defaults.publisher(.lockScreenWeatherShowsLocation, options: [])
|
||||
.map { _ in () }.eraseToAnyPublisher(),
|
||||
Defaults.publisher(.lockScreenWeatherShowsCharging, options: [])
|
||||
.map { _ in () }.eraseToAnyPublisher(),
|
||||
Defaults.publisher(.lockScreenWeatherShowsChargingPercentage, options: [])
|
||||
.map { _ in () }.eraseToAnyPublisher(),
|
||||
Defaults.publisher(.lockScreenWeatherShowsBluetooth, options: [])
|
||||
.map { _ in () }.eraseToAnyPublisher(),
|
||||
Defaults.publisher(.lockScreenWeatherShowsBatteryGauge, options: [])
|
||||
.map { _ in () }.eraseToAnyPublisher(),
|
||||
Defaults.publisher(.lockScreenWeatherWidgetStyle, options: [])
|
||||
.map { _ in () }.eraseToAnyPublisher(),
|
||||
Defaults.publisher(.lockScreenWeatherTemperatureUnit, options: [])
|
||||
.map { _ in () }.eraseToAnyPublisher(),
|
||||
Defaults.publisher(.lockScreenWeatherShowsAQI, options: [])
|
||||
.map { _ in () }.eraseToAnyPublisher(),
|
||||
Defaults.publisher(.lockScreenWeatherUsesGaugeTint, options: [])
|
||||
.map { _ in () }.eraseToAnyPublisher(),
|
||||
Defaults.publisher(.lockScreenWeatherProviderSource, options: [])
|
||||
.map { _ in () }.eraseToAnyPublisher()
|
||||
]
|
||||
|
||||
Publishers.MergeMany(defaultsPublishers)
|
||||
.sink { [weak self] in
|
||||
self?.handleAccessoryUpdate(triggerBluetoothRefresh: true)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Defaults.publisher(.lockScreenWeatherProviderSource, options: [])
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.latestWeatherPayload = nil
|
||||
Task { @MainActor in
|
||||
NSLog("LockScreenWeatherManager: provider changed, forcing refresh")
|
||||
_ = await self.refresh(force: true)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Defaults.publisher(.lockScreenWeatherTemperatureUnit, options: [])
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.latestWeatherPayload = nil
|
||||
Task { @MainActor in
|
||||
NSLog("LockScreenWeatherManager: temperature unit changed, forcing refresh")
|
||||
_ = await self.refresh(force: true)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Defaults.publisher(.enableLockScreenWeatherWidget, options: [])
|
||||
.sink { [weak self] change in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
if change.newValue {
|
||||
NSLog("LockScreenWeatherManager: widget enabled, triggering refresh")
|
||||
self.locationProvider.prepareAuthorization()
|
||||
_ = await self.refresh(force: true)
|
||||
} else {
|
||||
LockScreenWeatherPanelManager.shared.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func handleAccessoryUpdate(triggerBluetoothRefresh: Bool) {
|
||||
guard let payload = latestWeatherPayload else { return }
|
||||
|
||||
if triggerBluetoothRefresh {
|
||||
BluetoothAudioManager.shared.refreshConnectedDeviceBatteries()
|
||||
}
|
||||
|
||||
let snapshot = makeSnapshot(from: payload)
|
||||
self.snapshot = snapshot
|
||||
deliver(snapshot, forceShow: false)
|
||||
}
|
||||
|
||||
private func deliver(_ snapshot: LockScreenWeatherSnapshot, forceShow: Bool) {
|
||||
guard LockScreenManager.shared.currentLockStatus else { return }
|
||||
|
||||
if forceShow {
|
||||
LockScreenWeatherPanelManager.shared.show(with: snapshot)
|
||||
} else {
|
||||
LockScreenWeatherPanelManager.shared.update(with: snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSnapshot(from payload: LockScreenWeatherSnapshot) -> LockScreenWeatherSnapshot {
|
||||
let locationName = payload.locationName
|
||||
let chargingInfo = Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil
|
||||
let bluetoothInfo = Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil
|
||||
let widgetStyle = Defaults[.lockScreenWeatherWidgetStyle]
|
||||
let shouldShowLocation = widgetStyle == .inline && Defaults[.lockScreenWeatherShowsLocation] && !(locationName?.isEmpty ?? true)
|
||||
let showsChargingPercentage = Defaults[.lockScreenWeatherShowsChargingPercentage]
|
||||
let providerSource = Defaults[.lockScreenWeatherProviderSource]
|
||||
let airQualityInfo = (Defaults[.lockScreenWeatherShowsAQI] && providerSource.supportsAirQuality) ? payload.airQuality : nil
|
||||
let batteryInfo = Defaults[.lockScreenWeatherShowsBatteryGauge] ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil) : nil
|
||||
let usesGaugeTint = Defaults[.lockScreenWeatherUsesGaugeTint]
|
||||
|
||||
return LockScreenWeatherSnapshot(
|
||||
temperatureText: payload.temperatureText,
|
||||
symbolName: payload.symbolName,
|
||||
description: payload.description,
|
||||
locationName: locationName,
|
||||
charging: chargingInfo,
|
||||
bluetooth: bluetoothInfo,
|
||||
battery: batteryInfo,
|
||||
showsLocation: shouldShowLocation,
|
||||
airQuality: airQualityInfo,
|
||||
widgetStyle: widgetStyle,
|
||||
showsChargingPercentage: showsChargingPercentage,
|
||||
temperatureInfo: payload.temperatureInfo,
|
||||
usesGaugeTint: usesGaugeTint
|
||||
)
|
||||
}
|
||||
|
||||
private func makeChargingInfo() -> LockScreenWeatherSnapshot.ChargingInfo? {
|
||||
let battery = BatteryStatusViewModel.shared
|
||||
let macStatus = MacBatteryManager.shared.currentStatus()
|
||||
|
||||
let isPluggedIn = battery.isPluggedIn || battery.isCharging
|
||||
let isCharging = macStatus.isCharging || battery.isCharging
|
||||
|
||||
guard isPluggedIn || isCharging else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let rawMinutes = macStatus.timeRemainingMinutes ?? (battery.timeToFullCharge > 0 ? battery.timeToFullCharge : nil)
|
||||
let remaining = (rawMinutes ?? 0) > 0 ? rawMinutes : nil
|
||||
|
||||
let rawLevel = Int(round(Double(battery.levelBattery)))
|
||||
let clampedLevel = min(max(rawLevel, 0), 100)
|
||||
|
||||
return LockScreenWeatherSnapshot.ChargingInfo(
|
||||
minutesRemaining: remaining,
|
||||
isCharging: isCharging,
|
||||
isPluggedIn: isPluggedIn,
|
||||
batteryLevel: isPluggedIn || isCharging ? clampedLevel : nil
|
||||
)
|
||||
}
|
||||
|
||||
private func makeBatteryGaugeInfo(isCharging: Bool) -> LockScreenWeatherSnapshot.BatteryInfo? {
|
||||
guard !isCharging else { return nil }
|
||||
|
||||
let battery = BatteryStatusViewModel.shared
|
||||
let rawLevel = Int(round(Double(battery.levelBattery)))
|
||||
let clampedLevel = min(max(rawLevel, 0), 100)
|
||||
|
||||
guard clampedLevel >= 0 else { return nil }
|
||||
|
||||
return LockScreenWeatherSnapshot.BatteryInfo(batteryLevel: clampedLevel)
|
||||
}
|
||||
|
||||
private func makeBluetoothInfo() -> LockScreenWeatherSnapshot.BluetoothInfo? {
|
||||
let manager = BluetoothAudioManager.shared
|
||||
|
||||
guard manager.isBluetoothAudioConnected else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let device = manager.connectedDevices.last ?? manager.lastConnectedDevice
|
||||
|
||||
guard let device else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let batteryLevel = device.batteryLevel else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return LockScreenWeatherSnapshot.BluetoothInfo(
|
||||
deviceName: device.name,
|
||||
batteryLevel: clampBluetoothBatteryLevel(batteryLevel),
|
||||
iconName: device.deviceType.sfSymbol
|
||||
)
|
||||
}
|
||||
|
||||
private func clampBluetoothBatteryLevel(_ level: Int) -> Int {
|
||||
min(max(level, 0), 100)
|
||||
}
|
||||
}
|
||||
|
||||
struct LockScreenWeatherSnapshot: Equatable {
|
||||
struct TemperatureInfo: Equatable {
|
||||
let current: Double
|
||||
let minimum: Double?
|
||||
let maximum: Double?
|
||||
let unitSymbol: String
|
||||
|
||||
var displayMinimum: String? {
|
||||
guard let minimum else { return nil }
|
||||
return Self.formatted(value: minimum)
|
||||
}
|
||||
|
||||
var displayMaximum: String? {
|
||||
guard let maximum else { return nil }
|
||||
return Self.formatted(value: maximum)
|
||||
}
|
||||
|
||||
var displayCurrent: String {
|
||||
Self.formatted(value: current)
|
||||
}
|
||||
|
||||
private static func formatted(value: Double) -> String {
|
||||
let rounded = Int(round(value))
|
||||
return "\(rounded)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ChargingInfo: Equatable {
|
||||
let minutesRemaining: Int?
|
||||
let isCharging: Bool
|
||||
let isPluggedIn: Bool
|
||||
let batteryLevel: Int?
|
||||
|
||||
var iconName: String {
|
||||
if isCharging {
|
||||
return "bolt.fill"
|
||||
}
|
||||
if isPluggedIn {
|
||||
return "powerplug.portrait.fill"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
struct BluetoothInfo: Equatable {
|
||||
let deviceName: String
|
||||
let batteryLevel: Int
|
||||
let iconName: String
|
||||
}
|
||||
|
||||
struct BatteryInfo: Equatable {
|
||||
let batteryLevel: Int
|
||||
}
|
||||
|
||||
struct AirQualityInfo: Equatable {
|
||||
enum Category: String, Equatable {
|
||||
case good
|
||||
case moderate
|
||||
case unhealthyForSensitive
|
||||
case unhealthy
|
||||
case veryUnhealthy
|
||||
case hazardous
|
||||
case unknown
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .good: return "Good"
|
||||
case .moderate: return "Moderate"
|
||||
case .unhealthyForSensitive: return "Sensitive"
|
||||
case .unhealthy: return "Unhealthy"
|
||||
case .veryUnhealthy: return "Very Unhealthy"
|
||||
case .hazardous: return "Hazardous"
|
||||
case .unknown: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let index: Int
|
||||
let category: Category
|
||||
}
|
||||
|
||||
let temperatureText: String
|
||||
let symbolName: String
|
||||
let description: String
|
||||
let locationName: String?
|
||||
let charging: ChargingInfo?
|
||||
let bluetooth: BluetoothInfo?
|
||||
let battery: BatteryInfo?
|
||||
let showsLocation: Bool
|
||||
let airQuality: AirQualityInfo?
|
||||
let widgetStyle: LockScreenWeatherWidgetStyle
|
||||
let showsChargingPercentage: Bool
|
||||
let temperatureInfo: TemperatureInfo?
|
||||
let usesGaugeTint: Bool
|
||||
}
|
||||
|
||||
extension LockScreenWeatherSnapshot.AirQualityInfo.Category {
|
||||
init(aqiValue: Int) {
|
||||
switch aqiValue {
|
||||
case ..<0:
|
||||
self = .unknown
|
||||
case 0...50:
|
||||
self = .good
|
||||
case 51...100:
|
||||
self = .moderate
|
||||
case 101...150:
|
||||
self = .unhealthyForSensitive
|
||||
case 151...200:
|
||||
self = .unhealthy
|
||||
case 201...300:
|
||||
self = .veryUnhealthy
|
||||
case 301...:
|
||||
self = .hazardous
|
||||
default:
|
||||
self = .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private actor LockScreenWeatherProvider {
|
||||
|
|
@ -82,6 +494,23 @@ private actor LockScreenWeatherProvider {
|
|||
}
|
||||
|
||||
func fetchSnapshot(location: CLLocation?) async throws -> LockScreenWeatherSnapshot {
|
||||
let source = Defaults[.lockScreenWeatherProviderSource]
|
||||
return try await fetchSnapshot(location: location, source: source)
|
||||
}
|
||||
|
||||
func fetchSnapshot(location: CLLocation?, source: LockScreenWeatherProviderSource) async throws -> LockScreenWeatherSnapshot {
|
||||
switch source {
|
||||
case .wttr:
|
||||
return try await fetchWttrSnapshot(location: location)
|
||||
case .openMeteo:
|
||||
guard let location else {
|
||||
return try await fetchWttrSnapshot(location: nil)
|
||||
}
|
||||
return try await fetchOpenMeteoSnapshot(location: location)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchWttrSnapshot(location: CLLocation?) async throws -> LockScreenWeatherSnapshot {
|
||||
let locationSuffix: String
|
||||
if let coordinate = location?.coordinate {
|
||||
let lat = String(format: "%.4f", coordinate.latitude)
|
||||
|
|
@ -91,7 +520,8 @@ private actor LockScreenWeatherProvider {
|
|||
locationSuffix = ""
|
||||
}
|
||||
|
||||
let urlString = locationSuffix.isEmpty ? "https://wttr.in/?format=j1" : "https://wttr.in/\(locationSuffix)?format=j1"
|
||||
let query = "?format=j1&aqi=yes"
|
||||
let urlString = locationSuffix.isEmpty ? "https://wttr.in/\(query)" : "https://wttr.in/\(locationSuffix)\(query)"
|
||||
|
||||
guard let url = URL(string: urlString) else {
|
||||
throw WeatherProviderError.invalidURL
|
||||
|
|
@ -107,9 +537,29 @@ private actor LockScreenWeatherProvider {
|
|||
throw WeatherProviderError.noData
|
||||
}
|
||||
|
||||
let usesMetric = Locale.current.usesMetricSystem
|
||||
let temperature = usesMetric ? condition.tempC : condition.tempF
|
||||
let temperatureText = "\(temperature)°"
|
||||
let unit = Defaults[.lockScreenWeatherTemperatureUnit]
|
||||
let usesMetric = unit.usesMetricSystem
|
||||
let rawTemperature = usesMetric ? condition.tempC : condition.tempF
|
||||
let temperatureValue = Double(rawTemperature) ?? 0
|
||||
let temperatureText = "\(Int(round(temperatureValue)))°"
|
||||
let unitSymbol = unit.symbol
|
||||
|
||||
let forecast = payload.dailyWeather.first
|
||||
let minTempValue = forecast.flatMap { daily in
|
||||
let value = usesMetric ? daily.mintempC : daily.mintempF
|
||||
return value.flatMap(Double.init)
|
||||
}
|
||||
let maxTempValue = forecast.flatMap { daily in
|
||||
let value = usesMetric ? daily.maxtempC : daily.maxtempF
|
||||
return value.flatMap(Double.init)
|
||||
}
|
||||
|
||||
let temperatureInfo = LockScreenWeatherSnapshot.TemperatureInfo(
|
||||
current: temperatureValue,
|
||||
minimum: minTempValue,
|
||||
maximum: maxTempValue,
|
||||
unitSymbol: unitSymbol
|
||||
)
|
||||
|
||||
let code = Int(condition.weatherCode) ?? 113
|
||||
let symbol = WeatherSymbolMapper.symbol(for: code)
|
||||
|
|
@ -118,11 +568,137 @@ private actor LockScreenWeatherProvider {
|
|||
let nearest = payload.nearestArea.first
|
||||
let locationName = nearest?.preferredName
|
||||
|
||||
let airQualityInfo: LockScreenWeatherSnapshot.AirQualityInfo?
|
||||
if let index = condition.airQuality?.usIndexValue {
|
||||
airQualityInfo = LockScreenWeatherSnapshot.AirQualityInfo(
|
||||
index: index,
|
||||
category: LockScreenWeatherSnapshot.AirQualityInfo.Category(aqiValue: index)
|
||||
)
|
||||
} else {
|
||||
airQualityInfo = nil
|
||||
}
|
||||
|
||||
return LockScreenWeatherSnapshot(
|
||||
temperatureText: temperatureText,
|
||||
symbolName: symbol,
|
||||
description: description,
|
||||
locationName: locationName
|
||||
locationName: locationName,
|
||||
charging: nil,
|
||||
bluetooth: nil,
|
||||
battery: nil,
|
||||
showsLocation: true,
|
||||
airQuality: airQualityInfo,
|
||||
widgetStyle: .inline,
|
||||
showsChargingPercentage: true,
|
||||
temperatureInfo: temperatureInfo,
|
||||
usesGaugeTint: true
|
||||
)
|
||||
}
|
||||
|
||||
private func fetchOpenMeteoSnapshot(location: CLLocation) async throws -> LockScreenWeatherSnapshot {
|
||||
let latitude = String(format: "%.4f", location.coordinate.latitude)
|
||||
let longitude = String(format: "%.4f", location.coordinate.longitude)
|
||||
|
||||
let unit = Defaults[.lockScreenWeatherTemperatureUnit]
|
||||
let usesMetric = unit.usesMetricSystem
|
||||
var weatherComponents = URLComponents(string: "https://api.open-meteo.com/v1/forecast")
|
||||
var weatherQueryItems: [URLQueryItem] = [
|
||||
URLQueryItem(name: "latitude", value: latitude),
|
||||
URLQueryItem(name: "longitude", value: longitude),
|
||||
URLQueryItem(name: "current", value: "temperature_2m,weather_code,pressure_msl"),
|
||||
URLQueryItem(name: "daily", value: "temperature_2m_max,temperature_2m_min"),
|
||||
URLQueryItem(name: "forecast_days", value: "1"),
|
||||
URLQueryItem(name: "timezone", value: "auto")
|
||||
]
|
||||
|
||||
if let temperatureUnitParameter = unit.openMeteoTemperatureParameter {
|
||||
weatherQueryItems.append(URLQueryItem(name: "temperature_unit", value: temperatureUnitParameter))
|
||||
}
|
||||
|
||||
weatherComponents?.queryItems = weatherQueryItems
|
||||
|
||||
guard let weatherURL = weatherComponents?.url else {
|
||||
throw WeatherProviderError.invalidURL
|
||||
}
|
||||
|
||||
let (weatherData, weatherResponse) = try await session.data(from: weatherURL)
|
||||
guard let weatherHTTP = weatherResponse as? HTTPURLResponse, (200..<300).contains(weatherHTTP.statusCode) else {
|
||||
throw WeatherProviderError.invalidResponse
|
||||
}
|
||||
|
||||
let weatherDecoder = JSONDecoder()
|
||||
weatherDecoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
let weatherPayload = try weatherDecoder.decode(OpenMeteoForecastResponse.self, from: weatherData)
|
||||
guard let current = weatherPayload.current else {
|
||||
throw WeatherProviderError.noData
|
||||
}
|
||||
|
||||
let temperatureValue = current.temperature2M ?? 0
|
||||
let temperatureText = "\(Int(round(temperatureValue)))°"
|
||||
let code = current.weatherCode ?? 0
|
||||
let mapping = OpenMeteoSymbolMapper.mapping(for: code)
|
||||
let unitSymbol = unit.symbol
|
||||
let minTempValue = weatherPayload.daily?.temperature2MMin?.first
|
||||
let maxTempValue = weatherPayload.daily?.temperature2MMax?.first
|
||||
|
||||
let temperatureInfo = LockScreenWeatherSnapshot.TemperatureInfo(
|
||||
current: temperatureValue,
|
||||
minimum: minTempValue,
|
||||
maximum: maxTempValue,
|
||||
unitSymbol: unitSymbol
|
||||
)
|
||||
|
||||
var airQualityInfo: LockScreenWeatherSnapshot.AirQualityInfo?
|
||||
if Defaults[.lockScreenWeatherShowsAQI] {
|
||||
airQualityInfo = try? await fetchOpenMeteoAirQuality(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
|
||||
return LockScreenWeatherSnapshot(
|
||||
temperatureText: temperatureText,
|
||||
symbolName: mapping.symbol,
|
||||
description: mapping.description,
|
||||
locationName: nil,
|
||||
charging: nil,
|
||||
bluetooth: nil,
|
||||
battery: nil,
|
||||
showsLocation: true,
|
||||
airQuality: airQualityInfo,
|
||||
widgetStyle: .inline,
|
||||
showsChargingPercentage: true,
|
||||
temperatureInfo: temperatureInfo,
|
||||
usesGaugeTint: true
|
||||
)
|
||||
}
|
||||
|
||||
private func fetchOpenMeteoAirQuality(latitude: String, longitude: String) async throws -> LockScreenWeatherSnapshot.AirQualityInfo? {
|
||||
var airComponents = URLComponents(string: "https://air-quality-api.open-meteo.com/v1/air-quality")
|
||||
airComponents?.queryItems = [
|
||||
URLQueryItem(name: "latitude", value: latitude),
|
||||
URLQueryItem(name: "longitude", value: longitude),
|
||||
URLQueryItem(name: "current", value: "us_aqi"),
|
||||
URLQueryItem(name: "timezone", value: "auto")
|
||||
]
|
||||
|
||||
guard let airURL = airComponents?.url else {
|
||||
throw WeatherProviderError.invalidURL
|
||||
}
|
||||
|
||||
let (airData, airResponse) = try await session.data(from: airURL)
|
||||
guard let airHTTP = airResponse as? HTTPURLResponse, (200..<300).contains(airHTTP.statusCode) else {
|
||||
throw WeatherProviderError.invalidResponse
|
||||
}
|
||||
|
||||
let airDecoder = JSONDecoder()
|
||||
airDecoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
let airPayload = try airDecoder.decode(OpenMeteoAirQualityResponse.self, from: airData)
|
||||
guard let airCurrent = airPayload.current, let indexValue = airCurrent.usAqi else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let index = Int(round(indexValue))
|
||||
return LockScreenWeatherSnapshot.AirQualityInfo(
|
||||
index: index,
|
||||
category: LockScreenWeatherSnapshot.AirQualityInfo.Category(aqiValue: index)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -136,9 +712,11 @@ enum WeatherProviderError: Error {
|
|||
private struct WTTRResponse: Decodable {
|
||||
let current_condition: [WTTRCurrentCondition]
|
||||
let nearest_area: [WTTRNearestArea]?
|
||||
let weather: [WTTRDailyWeather]?
|
||||
|
||||
var currentCondition: [WTTRCurrentCondition] { current_condition }
|
||||
var nearestArea: [WTTRNearestArea] { nearest_area ?? [] }
|
||||
var dailyWeather: [WTTRDailyWeather] { weather ?? [] }
|
||||
}
|
||||
|
||||
private struct WTTRCurrentCondition: Decodable {
|
||||
|
|
@ -148,6 +726,9 @@ private struct WTTRCurrentCondition: Decodable {
|
|||
case weatherCode
|
||||
case weatherDesc
|
||||
case langEn = "lang_en"
|
||||
case pressure = "pressure"
|
||||
case pressureInches = "pressureInches"
|
||||
case airQuality = "air_quality"
|
||||
}
|
||||
|
||||
let tempC: String
|
||||
|
|
@ -155,6 +736,9 @@ private struct WTTRCurrentCondition: Decodable {
|
|||
let weatherCode: String
|
||||
let weatherDesc: [WTTRTextValue]?
|
||||
let langEn: [WTTRTextValue]?
|
||||
let pressure: String?
|
||||
let pressureInches: String?
|
||||
let airQuality: WTTRAirQuality?
|
||||
|
||||
var localizedDescription: String {
|
||||
if let english = langEn?.first?.value, !english.isEmpty {
|
||||
|
|
@ -171,6 +755,35 @@ private struct WTTRTextValue: Decodable {
|
|||
let value: String
|
||||
}
|
||||
|
||||
private struct WTTRAirQuality: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case usEpaIndex = "us-epa-index"
|
||||
case gbDefraIndex = "gb-defra-index"
|
||||
}
|
||||
|
||||
let usEpaIndex: String?
|
||||
let gbDefraIndex: String?
|
||||
|
||||
var usIndexValue: Int? {
|
||||
guard let value = usEpaIndex else { return nil }
|
||||
return Int(value)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WTTRDailyWeather: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case maxtempC = "maxtempC"
|
||||
case maxtempF = "maxtempF"
|
||||
case mintempC = "mintempC"
|
||||
case mintempF = "mintempF"
|
||||
}
|
||||
|
||||
let maxtempC: String?
|
||||
let maxtempF: String?
|
||||
let mintempC: String?
|
||||
let mintempF: String?
|
||||
}
|
||||
|
||||
private struct WTTRNearestArea: Decodable {
|
||||
let areaName: [WTTRTextValue]?
|
||||
let region: [WTTRTextValue]?
|
||||
|
|
@ -196,6 +809,69 @@ private struct WTTRNearestArea: Decodable {
|
|||
}
|
||||
}
|
||||
|
||||
private struct OpenMeteoForecastResponse: Decodable {
|
||||
struct Current: Decodable {
|
||||
let time: String?
|
||||
let temperature2M: Double?
|
||||
let weatherCode: Int?
|
||||
let pressureMsl: Double?
|
||||
}
|
||||
|
||||
let current: Current?
|
||||
let daily: Daily?
|
||||
|
||||
struct Daily: Decodable {
|
||||
let temperature2MMax: [Double]?
|
||||
let temperature2MMin: [Double]?
|
||||
}
|
||||
}
|
||||
|
||||
private struct OpenMeteoAirQualityResponse: Decodable {
|
||||
struct Current: Decodable {
|
||||
let time: String?
|
||||
let usAqi: Double?
|
||||
}
|
||||
|
||||
let current: Current?
|
||||
}
|
||||
|
||||
private enum OpenMeteoSymbolMapper {
|
||||
static func mapping(for code: Int) -> (symbol: String, description: String) {
|
||||
switch code {
|
||||
case 0:
|
||||
return ("sun.max.fill", "Clear sky")
|
||||
case 1:
|
||||
return ("cloud.sun.fill", "Mainly clear")
|
||||
case 2:
|
||||
return ("cloud.sun.fill", "Partly cloudy")
|
||||
case 3:
|
||||
return ("cloud.fill", "Overcast")
|
||||
case 45, 48:
|
||||
return ("cloud.fog.fill", "Fog")
|
||||
case 51, 53, 55:
|
||||
return ("cloud.drizzle.fill", "Drizzle")
|
||||
case 56, 57:
|
||||
return ("cloud.sleet.fill", "Freezing drizzle")
|
||||
case 61, 63, 65:
|
||||
return ("cloud.rain.fill", "Rain")
|
||||
case 66, 67:
|
||||
return ("cloud.sleet.fill", "Freezing rain")
|
||||
case 71, 73, 75, 77:
|
||||
return ("cloud.snow.fill", "Snow")
|
||||
case 80, 81, 82:
|
||||
return ("cloud.heavyrain.fill", "Rain showers")
|
||||
case 85, 86:
|
||||
return ("cloud.snow.fill", "Snow showers")
|
||||
case 95:
|
||||
return ("cloud.bolt.rain.fill", "Thunderstorm")
|
||||
case 96, 99:
|
||||
return ("cloud.bolt.rain.fill", "Thunderstorm with hail")
|
||||
default:
|
||||
return ("cloud.sun.fill", "Cloudy")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum WeatherSymbolMapper {
|
||||
static func symbol(for code: Int) -> String {
|
||||
switch code {
|
||||
|
|
|
|||
|
|
@ -80,7 +80,9 @@ final class LockScreenWeatherPanelManager {
|
|||
let originX = screenFrame.midX - (size.width / 2)
|
||||
let verticalOffset = screenFrame.height * 0.15
|
||||
let maxY = screenFrame.maxY - size.height - 48
|
||||
let originY = min(maxY, screenFrame.midY + verticalOffset)
|
||||
return NSRect(x: originX, y: originY, width: size.width, height: size.height)
|
||||
let baseY = min(maxY, screenFrame.midY + verticalOffset)
|
||||
let loweredY = baseY - 36
|
||||
let clampedY = max(screenFrame.minY + 80, loweredY)
|
||||
return NSRect(x: originX, y: clampedY, width: size.width, height: size.height)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
DynamicIsland/managers/MacBatteryManager.swift
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import Foundation
|
||||
import IOKit.ps
|
||||
|
||||
/// Lightweight helper for querying macOS battery charging status and ETA.
|
||||
final class MacBatteryManager {
|
||||
static let shared = MacBatteryManager()
|
||||
|
||||
private init() {}
|
||||
|
||||
struct BatteryStatus {
|
||||
let timeRemainingMinutes: Int?
|
||||
let isCharging: Bool
|
||||
let percentage: Int?
|
||||
}
|
||||
|
||||
func currentStatus() -> BatteryStatus {
|
||||
guard let sourcesInfo = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
|
||||
let sourcesList = IOPSCopyPowerSourcesList(sourcesInfo)?.takeRetainedValue() as? [CFTypeRef] else {
|
||||
return BatteryStatus(timeRemainingMinutes: nil, isCharging: false, percentage: nil)
|
||||
}
|
||||
|
||||
for source in sourcesList {
|
||||
guard let description = IOPSGetPowerSourceDescription(sourcesInfo, source)?.takeUnretainedValue() as? [String: Any],
|
||||
let type = description[kIOPSTypeKey] as? String,
|
||||
type == kIOPSInternalBatteryType else {
|
||||
continue
|
||||
}
|
||||
|
||||
let isCharging = description[kIOPSIsChargingKey] as? Bool ?? false
|
||||
let timeRemaining = description[kIOPSTimeToFullChargeKey] as? Int
|
||||
let currentCapacity = description[kIOPSCurrentCapacityKey] as? Int
|
||||
let maxCapacity = description[kIOPSMaxCapacityKey] as? Int
|
||||
|
||||
let percentage: Int?
|
||||
if let current = currentCapacity, let max = maxCapacity, max > 0 {
|
||||
percentage = (current * 100) / max
|
||||
} else {
|
||||
percentage = nil
|
||||
}
|
||||
|
||||
return BatteryStatus(
|
||||
timeRemainingMinutes: timeRemaining,
|
||||
isCharging: isCharging,
|
||||
percentage: percentage
|
||||
)
|
||||
}
|
||||
|
||||
return BatteryStatus(timeRemainingMinutes: nil, isCharging: false, percentage: nil)
|
||||
}
|
||||
|
||||
func formattedTimeToFullCharge() -> String? {
|
||||
let status = currentStatus()
|
||||
guard status.isCharging, let minutes = status.timeRemainingMinutes, minutes > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let hours = minutes / 60
|
||||
let remainingMinutes = minutes % 60
|
||||
|
||||
if hours > 0 {
|
||||
return "\(hours)h \(remainingMinutes)m"
|
||||
}
|
||||
return "\(remainingMinutes)m"
|
||||
}
|
||||
}
|
||||
|
|
@ -93,6 +93,9 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
|||
// MARK: - HUD Dispatch
|
||||
|
||||
private func sendVolumeNotification(value: Float) {
|
||||
if HUDSuppressionCoordinator.shared.shouldSuppressVolumeHUD {
|
||||
return
|
||||
}
|
||||
coordinator?.toggleSneakPeek(
|
||||
status: true,
|
||||
type: .volume,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,38 @@ extension Notification.Name {
|
|||
static let systemAudioRouteDidChange = Notification.Name("DynamicIsland.systemAudioRouteDidChange")
|
||||
}
|
||||
|
||||
final class HUDSuppressionCoordinator {
|
||||
static let shared = HUDSuppressionCoordinator()
|
||||
|
||||
private let queue = DispatchQueue(label: "com.dynamicisland.hud-suppression")
|
||||
private var volumeSuppressedUntil: Date?
|
||||
|
||||
func suppressVolumeHUD(for interval: TimeInterval) {
|
||||
guard interval > 0 else { return }
|
||||
queue.sync {
|
||||
let proposed = Date().addingTimeInterval(interval)
|
||||
if let current = volumeSuppressedUntil {
|
||||
volumeSuppressedUntil = max(current, proposed)
|
||||
} else {
|
||||
volumeSuppressedUntil = proposed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var shouldSuppressVolumeHUD: Bool {
|
||||
queue.sync {
|
||||
guard let expiration = volumeSuppressedUntil else {
|
||||
return false
|
||||
}
|
||||
if Date() < expiration {
|
||||
return true
|
||||
}
|
||||
volumeSuppressedUntil = nil
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class SystemVolumeController {
|
||||
static let shared = SystemVolumeController()
|
||||
|
||||
|
|
@ -46,6 +78,10 @@ final class SystemVolumeController {
|
|||
}
|
||||
|
||||
func adjust(by delta: Float) {
|
||||
guard delta != 0 else { return }
|
||||
if isMuted {
|
||||
setMuted(false)
|
||||
}
|
||||
var newValue = currentVolume + delta
|
||||
newValue = max(0, min(1, newValue))
|
||||
setVolume(newValue)
|
||||
|
|
@ -64,19 +100,54 @@ final class SystemVolumeController {
|
|||
}
|
||||
|
||||
func setVolume(_ value: Float) {
|
||||
var volume = max(0, min(1, value))
|
||||
let clamped = max(0, min(1, value))
|
||||
if isMuted && clamped > 0 {
|
||||
setMuted(false)
|
||||
}
|
||||
|
||||
let elements = volumeElements()
|
||||
|
||||
if elements.isEmpty {
|
||||
var volume = clamped
|
||||
let status = setData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
|
||||
if status != noErr {
|
||||
NSLog("⚠️ Failed to set volume: \(status)")
|
||||
}
|
||||
} else {
|
||||
for element in elements {
|
||||
var volume = clamped
|
||||
let status = setData(selector: kAudioDevicePropertyVolumeScalar, element: element, data: &volume)
|
||||
if status != noErr {
|
||||
NSLog("⚠️ Failed to set volume for element \(element): \(status)")
|
||||
} else {
|
||||
cache(element: element, for: kAudioDevicePropertyVolumeScalar)
|
||||
}
|
||||
}
|
||||
}
|
||||
notifyCurrentState()
|
||||
}
|
||||
|
||||
func setMuted(_ muted: Bool) {
|
||||
var muteFlag: UInt32 = muted ? 1 : 0
|
||||
let elements = muteElements()
|
||||
|
||||
if elements.isEmpty {
|
||||
let status = setData(selector: kAudioDevicePropertyMute, data: &muteFlag)
|
||||
if status != noErr {
|
||||
NSLog("⚠️ Failed to set mute state: \(status)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for element in elements {
|
||||
var value = muteFlag
|
||||
let status = setData(selector: kAudioDevicePropertyMute, element: element, data: &value)
|
||||
if status != noErr {
|
||||
NSLog("⚠️ Failed to set mute state for element \(element): \(status)")
|
||||
} else {
|
||||
cache(element: element, for: kAudioDevicePropertyMute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
|
@ -159,6 +230,9 @@ final class SystemVolumeController {
|
|||
}
|
||||
|
||||
private func getVolume() -> Float {
|
||||
let elements = volumeElements()
|
||||
|
||||
if elements.isEmpty {
|
||||
var volume = Float32(0)
|
||||
let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &volume)
|
||||
if status != noErr {
|
||||
|
|
@ -167,16 +241,76 @@ final class SystemVolumeController {
|
|||
return volume
|
||||
}
|
||||
|
||||
var masterVolume: Float?
|
||||
var accumulator: Float = 0
|
||||
var count: Float = 0
|
||||
|
||||
for element in elements {
|
||||
var value = Float32(0)
|
||||
let status = getData(selector: kAudioDevicePropertyVolumeScalar, element: element, data: &value)
|
||||
if status == noErr {
|
||||
if element == kAudioObjectPropertyElementMaster {
|
||||
masterVolume = value
|
||||
}
|
||||
accumulator += value
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
|
||||
if let masterVolume {
|
||||
return masterVolume
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return accumulator / count
|
||||
}
|
||||
|
||||
var fallback = Float32(0)
|
||||
let status = getData(selector: kAudioDevicePropertyVolumeScalar, data: &fallback)
|
||||
if status != noErr {
|
||||
NSLog("⚠️ Unable to fetch fallback volume: \(status)")
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private func getMuteState() -> Bool {
|
||||
let elements = muteElements()
|
||||
|
||||
if elements.isEmpty {
|
||||
var mute: UInt32 = 0
|
||||
let status = getData(selector: kAudioDevicePropertyMute, data: &mute)
|
||||
if status != noErr {
|
||||
// Some devices do not support mute; treat as not muted
|
||||
return false
|
||||
}
|
||||
return mute != 0
|
||||
}
|
||||
|
||||
var retrieved = false
|
||||
var allMuted = true
|
||||
|
||||
for element in elements {
|
||||
var value: UInt32 = 0
|
||||
let status = getData(selector: kAudioDevicePropertyMute, element: element, data: &value)
|
||||
if status == noErr {
|
||||
retrieved = true
|
||||
if value == 0 {
|
||||
allMuted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if retrieved {
|
||||
return allMuted
|
||||
}
|
||||
|
||||
var fallback: UInt32 = 0
|
||||
let status = getData(selector: kAudioDevicePropertyMute, data: &fallback)
|
||||
if status != noErr {
|
||||
return false
|
||||
}
|
||||
return fallback != 0
|
||||
}
|
||||
|
||||
private func refreshPropertyElements() {
|
||||
volumeElement = resolveElement(selector: kAudioDevicePropertyVolumeScalar, deviceID: currentDeviceID)
|
||||
muteElement = resolveElement(selector: kAudioDevicePropertyMute, deviceID: currentDeviceID)
|
||||
|
|
@ -264,6 +398,38 @@ final class SystemVolumeController {
|
|||
}
|
||||
return lastStatus
|
||||
}
|
||||
|
||||
private func getData<T>(selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement, data: inout T) -> OSStatus {
|
||||
var address = makeAddress(selector: selector, element: element)
|
||||
guard propertyExists(deviceID: currentDeviceID, address: &address) else {
|
||||
return kAudioHardwareUnknownPropertyError
|
||||
}
|
||||
var size = UInt32(MemoryLayout<T>.size)
|
||||
return AudioObjectGetPropertyData(currentDeviceID, &address, 0, nil, &size, &data)
|
||||
}
|
||||
|
||||
private func setData<T>(selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement, data: inout T) -> OSStatus {
|
||||
var address = makeAddress(selector: selector, element: element)
|
||||
guard propertyExists(deviceID: currentDeviceID, address: &address) else {
|
||||
return kAudioHardwareUnknownPropertyError
|
||||
}
|
||||
let size = UInt32(MemoryLayout<T>.size)
|
||||
return AudioObjectSetPropertyData(currentDeviceID, &address, 0, nil, size, &data)
|
||||
}
|
||||
|
||||
private func volumeElements() -> [AudioObjectPropertyElement] {
|
||||
candidateElements.filter { element in
|
||||
var address = makeAddress(selector: kAudioDevicePropertyVolumeScalar, element: element)
|
||||
return propertyExists(deviceID: currentDeviceID, address: &address)
|
||||
}
|
||||
}
|
||||
|
||||
private func muteElements() -> [AudioObjectPropertyElement] {
|
||||
candidateElements.filter { element in
|
||||
var address = makeAddress(selector: kAudioDevicePropertyMute, element: element)
|
||||
return propertyExists(deviceID: currentDeviceID, address: &address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class SystemBrightnessController {
|
||||
|
|
@ -275,9 +441,17 @@ final class SystemBrightnessController {
|
|||
private var observers: [NSObjectProtocol] = []
|
||||
private var notificationsInstalled = false
|
||||
private var displayID: CGDirectDisplayID = CGMainDisplayID()
|
||||
private var brightnessAnimationTimer: Timer?
|
||||
private var brightnessAnimationStart: Float = 0
|
||||
private var brightnessAnimationTarget: Float = 0
|
||||
private var brightnessAnimationStartDate: Date?
|
||||
private let brightnessAnimationDuration: TimeInterval = 0.18
|
||||
private let brightnessAnimationSteps = 10
|
||||
private var lastEmittedBrightness: Float = 0.5
|
||||
|
||||
private init() {
|
||||
registerExternalNotifications()
|
||||
lastEmittedBrightness = currentBrightness
|
||||
}
|
||||
|
||||
func start() {
|
||||
|
|
@ -286,6 +460,8 @@ final class SystemBrightnessController {
|
|||
|
||||
func stop() {
|
||||
onBrightnessChange = nil
|
||||
brightnessAnimationTimer?.invalidate()
|
||||
brightnessAnimationTimer = nil
|
||||
}
|
||||
|
||||
func adjust(by delta: Float) {
|
||||
|
|
@ -294,17 +470,8 @@ final class SystemBrightnessController {
|
|||
|
||||
func setBrightness(_ value: Float) {
|
||||
let clamped = max(0, min(1, value))
|
||||
if setBrightnessViaDisplayServices(clamped) {
|
||||
notifyCurrentBrightness()
|
||||
return
|
||||
}
|
||||
guard let service = displayService() else { return }
|
||||
let status = IODisplaySetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, clamped)
|
||||
IOObjectRelease(service)
|
||||
if status == kIOReturnSuccess {
|
||||
notifyCurrentBrightness()
|
||||
} else {
|
||||
NSLog("⚠️ Failed to set brightness via IODisplay: \(status)")
|
||||
DispatchQueue.main.async {
|
||||
self.beginBrightnessAnimation(to: clamped)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -324,10 +491,77 @@ final class SystemBrightnessController {
|
|||
|
||||
private func notifyCurrentBrightness() {
|
||||
let brightness = currentBrightness
|
||||
DispatchQueue.main.async {
|
||||
self.onBrightnessChange?(brightness)
|
||||
self.notificationCenter.post(name: .systemBrightnessDidChange, object: nil, userInfo: ["value": brightness])
|
||||
emitBrightnessChange(value: brightness)
|
||||
}
|
||||
|
||||
private func beginBrightnessAnimation(to target: Float) {
|
||||
brightnessAnimationTimer?.invalidate()
|
||||
|
||||
let start = lastEmittedBrightness
|
||||
if abs(start - target) <= 0.0005 {
|
||||
applyBrightness(target)
|
||||
emitBrightnessChange(value: target)
|
||||
return
|
||||
}
|
||||
|
||||
brightnessAnimationStart = start
|
||||
brightnessAnimationTarget = target
|
||||
brightnessAnimationStartDate = Date()
|
||||
|
||||
let interval = brightnessAnimationDuration / Double(brightnessAnimationSteps)
|
||||
let timer = Timer(timeInterval: interval, repeats: true) { [weak self] timer in
|
||||
guard let self else { return }
|
||||
guard let startDate = self.brightnessAnimationStartDate else {
|
||||
timer.invalidate()
|
||||
self.brightnessAnimationTimer = nil
|
||||
return
|
||||
}
|
||||
let elapsed = Date().timeIntervalSince(startDate)
|
||||
let progress = min(elapsed / self.brightnessAnimationDuration, 1)
|
||||
let eased = self.ease(progress)
|
||||
let value = self.brightnessAnimationStart + (self.brightnessAnimationTarget - self.brightnessAnimationStart) * Float(eased)
|
||||
self.applyBrightness(value)
|
||||
self.emitBrightnessChange(value: value)
|
||||
if progress >= 1 {
|
||||
timer.invalidate()
|
||||
self.brightnessAnimationTimer = nil
|
||||
}
|
||||
}
|
||||
brightnessAnimationTimer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
timer.fire()
|
||||
}
|
||||
|
||||
private func applyBrightness(_ value: Float) {
|
||||
let clamped = max(0, min(1, value))
|
||||
if setBrightnessViaDisplayServices(clamped) {
|
||||
return
|
||||
}
|
||||
guard let service = displayService() else { return }
|
||||
let status = IODisplaySetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString, clamped)
|
||||
IOObjectRelease(service)
|
||||
if status != kIOReturnSuccess {
|
||||
NSLog("⚠️ Failed to set brightness via IODisplay: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
private func emitBrightnessChange(value: Float) {
|
||||
let clamped = max(0, min(1, value))
|
||||
lastEmittedBrightness = clamped
|
||||
let dispatchBlock = {
|
||||
self.onBrightnessChange?(clamped)
|
||||
self.notificationCenter.post(name: .systemBrightnessDidChange, object: nil, userInfo: ["value": clamped])
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
dispatchBlock()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: dispatchBlock)
|
||||
}
|
||||
}
|
||||
|
||||
private func ease(_ t: Double) -> Double {
|
||||
let clamped = min(max(t, 0), 1)
|
||||
return 1 - pow(1 - clamped, 3)
|
||||
}
|
||||
|
||||
private func displayService() -> io_service_t? {
|
||||
|
|
@ -386,6 +620,7 @@ final class SystemBrightnessController {
|
|||
}
|
||||
|
||||
deinit {
|
||||
brightnessAnimationTimer?.invalidate()
|
||||
observers.forEach { DistributedNotificationCenter.default().removeObserver($0) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
199
DynamicIsland/managers/TimerControlWindowManager.swift
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
#if os(macOS)
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import SkyLightWindow
|
||||
import QuartzCore
|
||||
|
||||
struct TimerControlWindowMetrics {
|
||||
let notchHeight: CGFloat
|
||||
let notchWidth: CGFloat
|
||||
let rightWingWidth: CGFloat
|
||||
let cornerRadius: CGFloat
|
||||
let spacing: CGFloat
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TimerControlWindowManager {
|
||||
static let shared = TimerControlWindowManager()
|
||||
|
||||
private var window: NSWindow?
|
||||
private var hostingView: NSHostingView<TimerControlOverlay>?
|
||||
private var hasDelegated = false
|
||||
private var lastMetrics: TimerControlWindowMetrics?
|
||||
|
||||
private init() {}
|
||||
|
||||
@discardableResult
|
||||
func present(using viewModel: DynamicIslandViewModel, metrics: TimerControlWindowMetrics) -> Bool {
|
||||
guard let screen = resolveScreen(from: viewModel) else { return false }
|
||||
guard viewModel.effectiveClosedNotchHeight > 0, viewModel.closedNotchSize.width > 0 else {
|
||||
hide()
|
||||
return false
|
||||
}
|
||||
|
||||
let overlay = TimerControlOverlay(
|
||||
notchHeight: metrics.notchHeight,
|
||||
cornerRadius: metrics.cornerRadius
|
||||
)
|
||||
let hosting = ensureHostingView(with: overlay)
|
||||
let fittingSize = measuredSize(for: hosting)
|
||||
hosting.frame = NSRect(origin: .zero, size: fittingSize)
|
||||
|
||||
let window = ensureWindow(on: screen)
|
||||
if window.contentView !== hosting {
|
||||
window.contentView = hosting
|
||||
}
|
||||
|
||||
let targetFrame = frame(for: fittingSize, viewModel: viewModel, screen: screen, metrics: metrics)
|
||||
lastMetrics = metrics
|
||||
|
||||
if !hasDelegated {
|
||||
SkyLightOperator.shared.delegateWindow(window)
|
||||
hasDelegated = true
|
||||
}
|
||||
|
||||
if window.alphaValue <= 0.01 {
|
||||
let startFrame = initialFrame(for: targetFrame, metrics: metrics)
|
||||
window.setFrame(startFrame, display: true)
|
||||
window.alphaValue = 0
|
||||
window.orderFrontRegardless()
|
||||
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.24
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(targetFrame, display: true)
|
||||
window.animator().alphaValue = 1
|
||||
} completionHandler: {
|
||||
window.setFrame(targetFrame, display: false)
|
||||
}
|
||||
} else {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.16
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
window.animator().setFrame(targetFrame, display: true)
|
||||
} completionHandler: {
|
||||
window.setFrame(targetFrame, display: false)
|
||||
}
|
||||
window.orderFrontRegardless()
|
||||
window.alphaValue = 1
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func refresh(using viewModel: DynamicIslandViewModel, metrics: TimerControlWindowMetrics) -> Bool {
|
||||
guard window != nil else {
|
||||
return present(using: viewModel, metrics: metrics)
|
||||
}
|
||||
return present(using: viewModel, metrics: metrics)
|
||||
}
|
||||
|
||||
func hide(animated: Bool = true) {
|
||||
guard let window else { return }
|
||||
|
||||
guard animated, window.alphaValue > 0.01 else {
|
||||
window.orderOut(nil)
|
||||
window.alphaValue = 0
|
||||
return
|
||||
}
|
||||
|
||||
let retreatFrame = notchRetreatFrame(from: window.frame)
|
||||
let originalFrame = window.frame
|
||||
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.18
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
||||
window.animator().setFrame(retreatFrame, display: true)
|
||||
window.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
window.setFrame(originalFrame, display: false)
|
||||
window.orderOut(nil)
|
||||
window.alphaValue = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureHostingView(with overlay: TimerControlOverlay) -> NSHostingView<TimerControlOverlay> {
|
||||
if let hostingView {
|
||||
hostingView.rootView = overlay
|
||||
return hostingView
|
||||
}
|
||||
let view = NSHostingView(rootView: overlay)
|
||||
hostingView = view
|
||||
return view
|
||||
}
|
||||
|
||||
private func ensureWindow(on screen: NSScreen) -> NSWindow {
|
||||
if let window {
|
||||
return window
|
||||
}
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: screen.frame.midX, y: screen.frame.midY, width: 220, height: 64),
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
window.isReleasedWhenClosed = false
|
||||
window.isOpaque = false
|
||||
window.backgroundColor = .clear
|
||||
window.hasShadow = false
|
||||
window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
|
||||
window.ignoresMouseEvents = false
|
||||
window.isMovable = false
|
||||
window.alphaValue = 0
|
||||
|
||||
self.window = window
|
||||
self.hasDelegated = false
|
||||
return window
|
||||
}
|
||||
|
||||
private func measuredSize(for hosting: NSHostingView<TimerControlOverlay>) -> CGSize {
|
||||
hosting.layoutSubtreeIfNeeded()
|
||||
let size = hosting.fittingSize
|
||||
return CGSize(width: ceil(size.width), height: ceil(size.height))
|
||||
}
|
||||
|
||||
private func resolveScreen(from viewModel: DynamicIslandViewModel) -> NSScreen? {
|
||||
if let screenName = viewModel.screen,
|
||||
let targetScreen = NSScreen.screens.first(where: { $0.localizedName == screenName }) {
|
||||
return targetScreen
|
||||
}
|
||||
return NSScreen.main
|
||||
}
|
||||
|
||||
private func frame(for size: CGSize, viewModel: DynamicIslandViewModel, screen: NSScreen, metrics: TimerControlWindowMetrics) -> NSRect {
|
||||
let screenFrame = screen.frame
|
||||
let notchOriginX = screenFrame.midX - (metrics.notchWidth / 2)
|
||||
let originY = screenFrame.maxY - size.height
|
||||
|
||||
let rightEdge = notchOriginX + metrics.notchWidth + metrics.rightWingWidth
|
||||
let rawOriginX = rightEdge + metrics.spacing
|
||||
|
||||
let clampedOriginX = max(screenFrame.minX + 8, min(rawOriginX, screenFrame.maxX - size.width - 8))
|
||||
|
||||
return NSRect(x: clampedOriginX, y: originY, width: size.width, height: size.height)
|
||||
}
|
||||
|
||||
private func initialFrame(for targetFrame: NSRect, metrics: TimerControlWindowMetrics) -> NSRect {
|
||||
var frame = targetFrame
|
||||
let notchRightEdge = targetFrame.minX - metrics.spacing
|
||||
frame.origin.x = notchRightEdge - targetFrame.width
|
||||
return frame
|
||||
}
|
||||
|
||||
private func notchRetreatFrame(from currentFrame: NSRect) -> NSRect {
|
||||
guard let metrics = lastMetrics else {
|
||||
return currentFrame.offsetBy(dx: -currentFrame.width * 0.75, dy: 0)
|
||||
}
|
||||
|
||||
let notchRightEdge = currentFrame.minX - metrics.spacing
|
||||
var frame = currentFrame
|
||||
frame.origin.x = notchRightEdge - currentFrame.width
|
||||
return frame
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -10,6 +10,7 @@ import Combine
|
|||
import SwiftUI
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import Defaults
|
||||
|
||||
class TimerManager: ObservableObject {
|
||||
// MARK: - Properties
|
||||
|
|
@ -24,6 +25,7 @@ class TimerManager: ObservableObject {
|
|||
@Published var isFinished: Bool = false
|
||||
@Published var isOvertime: Bool = false // Timer has gone past 0 and is counting negative
|
||||
@Published var lastUpdated: Date = .distantPast
|
||||
@Published var activePresetId: UUID?
|
||||
|
||||
// Timer progress (0.0 to 1.0, or >1.0 for overtime)
|
||||
var progress: Double {
|
||||
|
|
@ -124,7 +126,7 @@ class TimerManager: ObservableObject {
|
|||
}
|
||||
|
||||
// MARK: - Timer Methods
|
||||
func startTimer(duration: TimeInterval, name: String = "Timer") {
|
||||
func startTimer(duration: TimeInterval, name: String = "Timer", preset: TimerPreset? = nil) {
|
||||
// Stop any existing timer
|
||||
timerInstance?.invalidate()
|
||||
|
||||
|
|
@ -141,7 +143,7 @@ class TimerManager: ObservableObject {
|
|||
isPaused = false
|
||||
lastUpdated = Date()
|
||||
|
||||
triggerTimerSneakPeek()
|
||||
activePresetId = preset?.id
|
||||
|
||||
// Start countdown timer
|
||||
timerInstance = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
|
|
@ -250,6 +252,13 @@ class TimerManager: ObservableObject {
|
|||
isPaused = false
|
||||
isFinished = false
|
||||
isOvertime = false
|
||||
activePresetId = nil
|
||||
}
|
||||
|
||||
// MARK: - Derived State
|
||||
var activePreset: TimerPreset? {
|
||||
guard let presetId = activePresetId else { return nil }
|
||||
return Defaults[.timerPresets].first { $0.id == presetId }
|
||||
}
|
||||
|
||||
private func scheduleSmoothClose() {
|
||||
|
|
@ -297,21 +306,6 @@ class TimerManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private func triggerTimerSneakPeek() {
|
||||
let coordinator = DynamicIslandViewCoordinator.shared
|
||||
|
||||
DispatchQueue.main.async {
|
||||
coordinator.sneakPeek.show = true
|
||||
coordinator.sneakPeek.type = .timer
|
||||
coordinator.sneakPeek.value = CGFloat(self.progress) // Use timer progress (0.0 to 1.0)
|
||||
|
||||
// Hide sneak peek after delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
||||
coordinator.sneakPeek.show = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Formatted Time Strings
|
||||
func formattedRemainingTime() -> String {
|
||||
if isOvertime && remainingTime < 0 {
|
||||
|
|
|
|||
|
|
@ -196,6 +196,27 @@ enum ProgressBarStyle: String, CaseIterable, Identifiable, Defaults.Serializable
|
|||
var id: String { self.rawValue }
|
||||
}
|
||||
|
||||
enum TimerIconColorMode: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||
case adaptive = "Adaptive"
|
||||
case solid = "Solid"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .adaptive: return "Adaptive gradient"
|
||||
case .solid: return "Solid colour"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TimerProgressStyle: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||
case bar = "Bar"
|
||||
case ring = "Ring"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
// AI Model types for screen assistant
|
||||
enum AIModelProvider: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||
case gemini = "Gemini"
|
||||
|
|
@ -334,10 +355,21 @@ extension Defaults.Keys {
|
|||
static let enableFullscreenMediaDetection = Key<Bool>("enableFullscreenMediaDetection", default: true)
|
||||
static let waitInterval = Key<Double>("waitInterval", default: 3)
|
||||
static let showShuffleAndRepeat = Key<Bool>("showShuffleAndRepeat", default: false)
|
||||
static let showMediaOutputControl = Key<Bool>("showMediaOutputControl", default: false)
|
||||
// Enable lock screen media widget (shows the standalone panel when screen is locked)
|
||||
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
||||
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
|
||||
static let lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60)
|
||||
static let lockScreenWeatherShowsLocation = Key<Bool>("lockScreenWeatherShowsLocation", default: true)
|
||||
static let lockScreenWeatherShowsCharging = Key<Bool>("lockScreenWeatherShowsCharging", default: true)
|
||||
static let lockScreenWeatherShowsChargingPercentage = Key<Bool>("lockScreenWeatherShowsChargingPercentage", default: true)
|
||||
static let lockScreenWeatherShowsBluetooth = Key<Bool>("lockScreenWeatherShowsBluetooth", default: true)
|
||||
static let lockScreenWeatherShowsBatteryGauge = Key<Bool>("lockScreenWeatherShowsBatteryGauge", default: true)
|
||||
static let lockScreenWeatherWidgetStyle = Key<LockScreenWeatherWidgetStyle>("lockScreenWeatherWidgetStyle", default: .circular)
|
||||
static let lockScreenWeatherTemperatureUnit = Key<LockScreenWeatherTemperatureUnit>("lockScreenWeatherTemperatureUnit", default: .celsius)
|
||||
static let lockScreenWeatherShowsAQI = Key<Bool>("lockScreenWeatherShowsAQI", default: false)
|
||||
static let lockScreenWeatherUsesGaugeTint = Key<Bool>("lockScreenWeatherUsesGaugeTint", default: false)
|
||||
static let lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .openMeteo)
|
||||
static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid)
|
||||
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
|
||||
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: true)
|
||||
|
|
@ -362,6 +394,7 @@ extension Defaults.Keys {
|
|||
static let enableGradient = Key<Bool>("enableGradient", default: false)
|
||||
static let systemEventIndicatorShadow = Key<Bool>("systemEventIndicatorShadow", default: false)
|
||||
static let systemEventIndicatorUseAccent = Key<Bool>("systemEventIndicatorUseAccent", default: false)
|
||||
static let showProgressPercentages = Key<Bool>("showProgressPercentages", default: false)
|
||||
|
||||
// MARK: Shelf
|
||||
static let dynamicShelf = Key<Bool>("dynamicShelf", default: true)
|
||||
|
|
@ -386,6 +419,9 @@ extension Defaults.Keys {
|
|||
static let useColorCodedBatteryDisplay = Key<Bool>("useColorCodedBatteryDisplay", default: true)
|
||||
static let useColorCodedVolumeDisplay = Key<Bool>("useColorCodedVolumeDisplay", default: false)
|
||||
static let useSmoothColorGradient = Key<Bool>("useSmoothColorGradient", default: true)
|
||||
static let useCircularBluetoothBatteryIndicator = Key<Bool>("useCircularBluetoothBatteryIndicator", default: true)
|
||||
static let showBluetoothBatteryPercentageText = Key<Bool>("showBluetoothBatteryPercentageText", default: true)
|
||||
static let showBluetoothDeviceNameMarquee = Key<Bool>("showBluetoothDeviceNameMarquee", default: true)
|
||||
|
||||
// MARK: Stats Feature
|
||||
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)
|
||||
|
|
@ -399,6 +435,14 @@ extension Defaults.Keys {
|
|||
|
||||
// MARK: Timer Feature
|
||||
static let enableTimerFeature = Key<Bool>("enableTimerFeature", default: true)
|
||||
static let timerPresets = Key<[TimerPreset]>("timerPresets", default: TimerPreset.defaultPresets)
|
||||
static let timerIconColorMode = Key<TimerIconColorMode>("timerIconColorMode", default: .adaptive)
|
||||
static let timerSolidColor = Key<Color>("timerSolidColor", default: .blue)
|
||||
static let timerShowsCountdown = Key<Bool>("timerShowsCountdown", default: true)
|
||||
static let timerShowsLabel = Key<Bool>("timerShowsLabel", default: true)
|
||||
static let timerShowsProgress = Key<Bool>("timerShowsProgress", default: true)
|
||||
static let timerProgressStyle = Key<TimerProgressStyle>("timerProgressStyle", default: .bar)
|
||||
static let timerControlWindowEnabled = Key<Bool>("timerControlWindowEnabled", default: true)
|
||||
|
||||
// MARK: ColorPicker Feature
|
||||
static let enableColorPickerFeature = Key<Bool>("enableColorPickerFeature", default: true)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
|||
@Published var isClipboardPopoverActive: Bool = false
|
||||
@Published var isColorPickerPopoverActive: Bool = false
|
||||
@Published var isStatsPopoverActive: Bool = false
|
||||
@Published var isMediaOutputPopoverActive: Bool = false
|
||||
@Published var isTimerPopoverActive: Bool = false
|
||||
@Published var shouldRecheckHover: Bool = false
|
||||
|
||||
let webcamManager = WebcamManager.shared
|
||||
|
|
|
|||
93
DynamicIsland/models/TimerPreset.swift
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
//
|
||||
// TimerPreset.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by GitHub Copilot on 2025-11-01.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import Defaults
|
||||
|
||||
struct TimerPreset: Identifiable, Codable, Hashable, Defaults.Serializable {
|
||||
struct ColorData: Codable, Hashable, Defaults.Serializable {
|
||||
var red: Double
|
||||
var green: Double
|
||||
var blue: Double
|
||||
var alpha: Double
|
||||
|
||||
init(red: Double, green: Double, blue: Double, alpha: Double = 1.0) {
|
||||
self.red = red
|
||||
self.green = green
|
||||
self.blue = blue
|
||||
self.alpha = alpha
|
||||
}
|
||||
|
||||
init(color: Color) {
|
||||
let nsColor = NSColor(color).usingColorSpace(.sRGB) ?? NSColor(color)
|
||||
self.red = nsColor.redComponent
|
||||
self.green = nsColor.greenComponent
|
||||
self.blue = nsColor.blueComponent
|
||||
self.alpha = nsColor.alphaComponent
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
Color(red: red, green: green, blue: blue, opacity: alpha)
|
||||
}
|
||||
}
|
||||
|
||||
struct DurationComponents {
|
||||
var hours: Int
|
||||
var minutes: Int
|
||||
var seconds: Int
|
||||
}
|
||||
|
||||
var id: UUID
|
||||
var name: String
|
||||
var duration: TimeInterval
|
||||
var colorData: ColorData
|
||||
|
||||
init(id: UUID = UUID(), name: String, duration: TimeInterval, colorData: ColorData) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.duration = duration
|
||||
self.colorData = colorData
|
||||
}
|
||||
|
||||
init(id: UUID = UUID(), name: String, duration: TimeInterval, color: Color) {
|
||||
self.init(id: id, name: name, duration: duration, colorData: ColorData(color: color))
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
colorData.color
|
||||
}
|
||||
|
||||
mutating func updateColor(_ newColor: Color) {
|
||||
colorData = ColorData(color: newColor)
|
||||
}
|
||||
|
||||
var formattedDuration: String {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
|
||||
formatter.zeroFormattingBehavior = [.pad]
|
||||
return formatter.string(from: duration) ?? "0:00"
|
||||
}
|
||||
|
||||
static func components(for duration: TimeInterval) -> DurationComponents {
|
||||
let totalSeconds = max(0, Int(duration))
|
||||
let hours = totalSeconds / 3600
|
||||
let minutes = (totalSeconds % 3600) / 60
|
||||
let seconds = totalSeconds % 60
|
||||
return DurationComponents(hours: hours, minutes: minutes, seconds: seconds)
|
||||
}
|
||||
|
||||
static func duration(from components: DurationComponents) -> TimeInterval {
|
||||
TimeInterval(components.hours * 3600 + components.minutes * 60 + components.seconds)
|
||||
}
|
||||
|
||||
static let defaultPresets: [TimerPreset] = [
|
||||
TimerPreset(name: "Focus", duration: 25 * 60, color: Color.orange),
|
||||
TimerPreset(name: "Break", duration: 5 * 60, color: Color.green),
|
||||
TimerPreset(name: "Deep Work", duration: 45 * 60, color: Color.purple)
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 26 MiB After Width: | Height: | Size: 133 B |
112
ReadMe.md
|
|
@ -2,7 +2,26 @@
|
|||
<img src=".github/assets/atoll-logo.png" alt="Atoll logo" width="120">
|
||||
</p>
|
||||
|
||||
> **Project rename:** DynamicIsland is now called **Atoll**. Visit the new repository at [github.com/Ebullioscopic/Atoll](https://github.com/Ebullioscopic/Atoll).
|
||||
<p align="center">
|
||||
<a href="https://github.com/Ebullioscopic/Atoll/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/Ebullioscopic/Atoll?style=social" alt="GitHub stars"/>
|
||||
</a>
|
||||
<a href="https://github.com/Ebullioscopic/Atoll/network/members">
|
||||
<img src="https://img.shields.io/github/forks/Ebullioscopic/Atoll?style=social" alt="GitHub forks"/>
|
||||
</a>
|
||||
<a href="https://github.com/Ebullioscopic/Atoll/releases">
|
||||
<img src="https://img.shields.io/github/downloads/Ebullioscopic/Atoll/total?label=Downloads" alt="GitHub downloads"/>
|
||||
</a>
|
||||
<a href="https://discord.gg/zvjB4tJBkt">
|
||||
<img src="https://img.shields.io/discord/1120176554840510494?label=Discord&logo=discord&color=7289da" alt="Discord server"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/zvjB4tJBkt">Join our Discord community</a>
|
||||
</p>
|
||||
|
||||
**Project rename:** DynamicIsland is now called **Atoll**. Visit the new repository at [github.com/Ebullioscopic/Atoll](https://github.com/Ebullioscopic/Atoll).
|
||||
|
||||
# Atoll
|
||||
|
||||
|
|
@ -57,6 +76,16 @@ The application provides a comprehensive interface for monitoring system perform
|
|||
<img src=".github/assets/atoll-clipboard.png" alt="Clipboard manager" width="320">
|
||||
</p>
|
||||
|
||||
### LockScreen Widgets
|
||||
- **Weather**
|
||||
- **Media Player**
|
||||
- **Device Charging status**
|
||||
- **Bluetooth Peripheral Battery status**
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/lockscreen.gif" alt="Lockscreen demo" width="720">
|
||||
</p>
|
||||
|
||||
### Layout and customisation
|
||||
- **Minimalistic mode** reducing the notch interface width from 640px to 420px, preserving essential music controls, HUDs, and timer functionality while removing secondary features for users prioritising visual simplicity.
|
||||
- **Per-feature toggles** enabling granular control over music controls, individual stats monitoring categories (CPU, memory, GPU, network, disk), clipboard manager display, colour picker, screen assistant, timers, and other modules.
|
||||
|
|
@ -67,6 +96,16 @@ The application provides a comprehensive interface for monitoring system perform
|
|||
- **Profile-driven onboarding** that preconfigures the entire experience based on user selection from Developer, Designer, Light Use, or Student profiles, with multi-select support for combined feature sets.
|
||||
- **Keyboard shortcut remapping** allowing users to customise hotkeys for toggling the notch, opening panels, activating the colour picker, starting timers, and launching utility tools.
|
||||
|
||||
### Circular widgets (lock screen)
|
||||
- **Circular / Gauge styles**: Lock screen widgets now include a dedicated circular mode that uses full circular gauges for charging, Bluetooth battery, AQI, and temperature. Gauges rely on the system accessory styles to match platform appearance and respect the monochrome tint preference.
|
||||
- **Inline vs circular**: Inline mode keeps icon + text on a single baseline (no gauges). Circular mode increases widget sizing to match iOS-like reference visuals and adds top padding so the widget sits visually aligned with the lock screen clock.
|
||||
- **AQI & provider handling**: AQI is shown only when the Open Meteo provider is active. A provider toggle in Settings selects between Open Meteo and wttr; AQI is disabled for wttr.
|
||||
- **Battery & charging behaviour**: Charging state hides the standalone battery gauge and shows charging-specific accessory circular capacity. Bluetooth circular badge displays only the icon with a surrounding progress ring; battery percentages display actual values (e.g., 100%) and adopt system colouring when appropriate.
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/CircularLockScreenWidgets.png" alt="Circular lock screen widgets" width="640">
|
||||
</p>
|
||||
|
||||
## Requirements
|
||||
- **macOS Sonoma 14.0** or later, optimised for macOS Sequoia 15.0 and subsequent releases.
|
||||
- **MacBook with notch** including 14-inch and 16-inch MacBook Pro models across M1 Pro, M1 Max, M2 Pro, M2 Max, M3 Pro, M3 Max, M4 Pro, and M4 Max configurations.
|
||||
|
|
@ -130,6 +169,16 @@ The application provides a comprehensive interface for monitoring system perform
|
|||
- Timers continue running as live activities even when the notch is closed, with notifications at completion.
|
||||
- Manage multiple concurrent timers and view remaining time for all active timers at a glance.
|
||||
|
||||
- Timer controls live in a right-side popover alongside other notch icons and are also available from the menu bar. Timers run as live activities when the notch is closed: the timer icon remains neutral when idle and adopts the configured tint while running.
|
||||
|
||||
- Progress and display options: choose circular ring or linear bar progress, toggle countdown text on/off, and allow the ring size to adapt to title width to avoid overlap with the notch.
|
||||
|
||||
- Settings: timer tint (gradient vs solid), progress style (circular or bar), and countdown visibility.
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/timer.gif" alt="Timer controls and live activity" width="640">
|
||||
</p>
|
||||
|
||||
### Customisation
|
||||
- Open Settings from the menu bar icon to adjust features, appearance, behaviour, keyboard shortcuts, and privacy permissions.
|
||||
- Switch between user profiles to quickly reconfigure the entire interface for different workflows.
|
||||
|
|
@ -265,57 +314,6 @@ This operation clears UserDefaults, removes application support files and caches
|
|||
- Search Console.app for process name "DynamicIsland" to filter relevant log entries.
|
||||
- Report persistent issues with Console logs attached at the project's GitHub Issues page.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome from developers, designers, and documentation writers. Follow these guidelines to ensure smooth collaboration:
|
||||
|
||||
### For developers
|
||||
1. **Fork the repository** and clone your fork locally:
|
||||
```bash
|
||||
git clone https://github.com/your-username/Atoll.git
|
||||
cd Atoll
|
||||
```
|
||||
2. **Create a feature branch** from main with a descriptive name:
|
||||
```bash
|
||||
git switch -c feature/your-feature-name
|
||||
```
|
||||
3. **Follow existing architecture**: Maintain the SwiftUI structure, leverage Combine for reactive data flow, and avoid introducing unnecessary dependencies.
|
||||
4. **Code style**: Follow Swift API Design Guidelines, use meaningful variable names, and keep functions focused on single responsibilities.
|
||||
5. **Testing**: Add unit tests for new functionality where practical, particularly for data models and utility functions.
|
||||
6. **Documentation**: Update inline documentation for public APIs and add explanatory comments for complex logic.
|
||||
7. **Commit changes** with clear, concise commit messages:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add feature: brief description of changes"
|
||||
```
|
||||
8. **Push to your fork** and submit a pull request:
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
### Pull request guidelines
|
||||
- Provide a clear description of the changes, motivation, and any related issues.
|
||||
- Include testing notes explaining how to verify the changes.
|
||||
- Attach screenshots or screen recordings for UI changes.
|
||||
- Ensure the code builds without errors or warnings.
|
||||
- Respond to review feedback promptly and update the PR as needed.
|
||||
|
||||
### For designers
|
||||
- Contribute UI/UX improvements, mockups, and design refinements.
|
||||
- Submit icon designs, animation concepts, or visual asset enhancements.
|
||||
- Provide design rationale and maintain consistency with macOS Human Interface Guidelines.
|
||||
|
||||
### For documentation
|
||||
- Improve user guides, API documentation, and troubleshooting sections.
|
||||
- Translate documentation into additional languages.
|
||||
- Clarify setup instructions and add missing usage examples.
|
||||
|
||||
### Code review process
|
||||
- All pull requests require review from project maintainers before merging.
|
||||
- Automated testing must pass via continuous integration workflows.
|
||||
- Changes should not significantly decrease test coverage without justification.
|
||||
- Breaking changes require major version updates following semantic versioning.
|
||||
|
||||
## License
|
||||
Atoll is released under the GPL v3 License. Refer to [LICENSE](LICENSE) for the full terms.
|
||||
|
||||
|
|
@ -329,7 +327,15 @@ Atoll builds upon the work of several open-source projects and draws inspiration
|
|||
|
||||
- [**Stats**](https://github.com/exelban/stats) - source implementation for CPU temperature monitoring via SMC (System Management Controller) access, frequency sampling through IOReport bindings, and per-core CPU utilisation tracking. The system metrics collection architecture derives from Stats project readers.
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/Ebullioscopic/Atoll/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=Ebullioscopic/Atoll" />
|
||||
</a>
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#Ebullioscopic/Atoll&type=timeline&legend=top-left)
|
||||
|
||||
## Updating Existing Clones
|
||||
If you previously cloned DynamicIsland, update the remote to track the Atoll repository:
|
||||
|
|
@ -337,3 +343,5 @@ If you previously cloned DynamicIsland, update the remote to track the Atoll rep
|
|||
```bash
|
||||
git remote set-url origin https://github.com/Ebullioscopic/Atoll.git
|
||||
```
|
||||
|
||||
A heartfelt thanks to [TheBoredTeam](https://github.com/TheBoredTeam) for being supportive and being totally awesome, Atoll would not have been possible without Boring.Notch
|
||||