diff --git a/.github/ISSUE_TEMPLATE/1-bug-report-form.yml b/.github/ISSUE_TEMPLATE/1-bug-report-form.yml index f1ddcff6..06237b11 100644 --- a/.github/ISSUE_TEMPLATE/1-bug-report-form.yml +++ b/.github/ISSUE_TEMPLATE/1-bug-report-form.yml @@ -30,12 +30,13 @@ body: - type: dropdown id: version attributes: - label: BoringNotch Version + label: Boring Notch Version description: >- What version of our software are you running? (Go to ✦ in the menu bar > Settings > About) options: - Select a version + - v2.7.3 - v2.7.2 - v2.7.1 - v2.7 @@ -50,6 +51,31 @@ body: description: Go to  > About This Mac validations: required: true + - type: dropdown + id: music-source + attributes: + label: Music Source? (If relevant) + description: >- + If this issue is related to music, what music source did you select in Boring Notch settings? + options: + - Now Playing + - Apple Music + - Spotify + - YouTube Music + validations: + required: false + - type: input + id: music-app + attributes: + label: Music App (If using Now Playing) + validations: + required: false + - type: input + id: music-website + attributes: + label: Website (If using browser for music) + validations: + required: false - type: textarea id: logs attributes: diff --git a/.github/ISSUE_TEMPLATE/1-feature-request-form.yml b/.github/ISSUE_TEMPLATE/1-feature-request-form.yml new file mode 100644 index 00000000..71c73fad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-feature-request-form.yml @@ -0,0 +1,80 @@ +name: Feature Request +description: Suggest a new idea or enhancement for Boring Notch +title: "[FEATURE] " +labels: [] + +body: + - type: markdown + attributes: + value: | + ## Feature Request + + Thanks for helping make **Boring Notch** better! Please check [existing requests](https://github.com/TheBoredTeam/boring.notch/issues?q=is%3Aissue) before submitting to avoid duplicates. + + - type: textarea + id: problem + attributes: + label: Is your feature request related to a problem? + description: | + Please provide a clear description of the problem or pain point this feature would address. + + **Example:** "I often miss my next meeting because I work in full-screen mode and can't see the system clock or calendar notifications." + placeholder: "I'm frustrated when... or It would be great if..." + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Describe the solution you'd like + description: | + How should Boring Notch handle this? Describe the behavior, UI, or interaction you imagine in detail. + + **Example:** "When a meeting is starting in 5 minutes, the notch could expand slightly to show a countdown timer or the meeting title." + placeholder: "I would like the notch to..." + validations: + required: true + + - type: textarea + id: use-cases + attributes: + label: Use cases & user scenarios + description: | + Describe specific scenarios where this feature would be valuable. Who would benefit from this and how often would it be used? + + **Example:** "Remote workers who attend 5+ video meetings daily would use this constantly. Students could benefit during online classes." + placeholder: "This would help users who..." + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Additional context & screenshots + description: | + Add any other context, mockups, wireframes, or screenshots about the feature request here. Visuals are incredibly helpful! + + You can drag and drop images directly into this field. + placeholder: "Here is a mockup of how the hover state should look..." + validations: + required: false + + - type: checkboxes + id: contribution + attributes: + label: Willingness to contribute + description: "We love community contributions! Would you be willing to help build this?" + options: + - label: "Yes, I can write the code for this feature" + - label: "Yes, I can help with design/mockups" + + - type: checkboxes + id: checklist + attributes: + label: Pre-submission checklist + description: "Please confirm the following before submitting:" + options: + - label: "I have searched existing issues to ensure this isn't a duplicate" + required: true + - label: "I have provided sufficient detail for the team to understand the request" + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index f63b4ac3..4fc5911d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,3 +1,4 @@ + --- name: Feature request about: Suggest an idea for this project diff --git a/.github/workflows/manual_build.yml b/.github/workflows/manual_build.yml index 33014bbd..9afd06c6 100644 --- a/.github/workflows/manual_build.yml +++ b/.github/workflows/manual_build.yml @@ -75,13 +75,24 @@ jobs: EOF xcodebuild -exportArchive -archivePath "${{ env.projname }}.xcarchive" -exportPath Release -exportOptionsPlist "$TEMP_PLIST" + - name: Install dmgbuild (use virtualenv) + run: | + # Create an isolated venv + VENV_DIR="$RUNNER_TEMP/venv" + python3 -m venv "$VENV_DIR" + source "$VENV_DIR/bin/activate" + python -m pip install --upgrade pip setuptools wheel + python -m pip install "dmgbuild[badge_icons]" + - name: Create DMG run: | - cd Release - hdiutil create -volname "boringNotch" \ - -srcfolder "${{ env.projname }}.app" \ - -ov -format UDZO \ - "${{ env.projname }}.dmg" + # Ensure venv created in previous step is used (dmgbuild installed there) + VENV_DIR="$RUNNER_TEMP/venv" + if [ -d "$VENV_DIR" ]; then + export PATH="$VENV_DIR/bin:$PATH" + fi + chmod +x Configuration/dmg/create_dmg.sh + ./Configuration/dmg/create_dmg.sh "Release/${{ env.projname }}.app" "Release/${{ env.projname }}.dmg" "${{ env.projname }}" - name: Upload DMG artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf8ca82f..b02383cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -245,13 +245,25 @@ jobs: exit 1 fi echo "Found generate_appcast at $TOOL_PATH" + - name: Install dmgbuild (use virtualenv) + run: | + # Create an isolated venv + VENV_DIR="$RUNNER_TEMP/venv" + python3 -m venv "$VENV_DIR" + # Install into the venv + source "$VENV_DIR/bin/activate" + python -m pip install --upgrade pip setuptools wheel + python -m pip install "dmgbuild[badge_icons]" + - name: Create DMG run: | - cd Release - hdiutil create -volname "boringNotch ${{ needs.preparation.outputs.version }}" \ - -srcfolder "${{ env.projname }}.app" \ - -ov -format UDZO \ - "${{ env.projname }}.dmg" + # Ensure venv created in previous step is used (dmgbuild installed there) + VENV_DIR="$RUNNER_TEMP/venv" + if [ -d "$VENV_DIR" ]; then + export PATH="$VENV_DIR/bin:$PATH" + fi + chmod +x Configuration/dmg/create_dmg.sh + ./Configuration/dmg/create_dmg.sh "Release/${{ env.projname }}.app" "Release/${{ env.projname }}.dmg" "boringNotch ${{ needs.preparation.outputs.version }}" - name: Upload DMG artifact uses: actions/upload-artifact@v4 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..332e5b2c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,130 @@ +# Contributing + +Thank you for taking the time to contribute! ❤️ + +These guidelines help streamline the contribution process for everyone involved. By following them, you'll make it easier for maintainers to review your work and collaborate with you effectively. + +You can contribute in many ways: writing code, improving documentation, reporting bugs, requesting features, or creating tutorials and blog posts. Every contribution, large or small, helps make Boring Notch better. + +## Table of Contents + +- [Localizations](#localizations) +- [Contributing Code](#contributing-code) + - [Before You Start](#before-you-start) + - [Setting Up Your Environment](#setting-up-your-environment) + - [Making Changes](#making-changes) + - [Pull Requests](#pull-requests) + +- [Reporting Bugs](#reporting-bugs) +- [Feature Requests](#feature-requests) +- [Getting Help](#getting-help) + +## Localizations + +Please submit all translations to [Crowdin](https://crowdin.com/project/boring-notch). New strings added to the `dev` branch from code changes will sync automatically to Crowdin, and Crowdin will automatically open a new PR with translations to allow us to integrate them. + +## Contributing Code + +### Before You Start + +- **Check existing issues**: Before creating a new issue or starting work, search existing issues to avoid duplicates. +- **Discuss major changes**: For significant features or major changes, please open an issue first to discuss your approach with maintainers and the community. + + +### Setting Up Your Environment + +1. **Fork the repository**: Click the "Fork" button at the top of the repository page to create your own copy. + +2. **Clone your fork**: + ```bash + git clone https://github.com/{your-username}/boring.notch.git + cd boring.notch + ``` + Replace `{your-username}` with your GitHub username. + +3. **Switch to the `dev` branch**: + ```bash + git checkout dev + ``` + All code contributions should be based on the `dev` branch, not `main`. (documentation corrections or improvements can be based on `main`) + +5. **Create a new feature branch**: + ```bash + git checkout -b feature/{your-feature-name} + ``` + Replace `{your-feature-name}` with a descriptive name. Use lowercase letters, numbers, and hyphens only (e.g., `feature/add-dark-mode` or `fix/notification-crash`). + +### Making Changes + +1. **Make your changes**: Implement your feature or bug fix. Write clean, well-documented code + +2. **Test your changes**: Ensure your changes work as expected and don't break existing functionality. + +3. **Commit your changes**: + ```bash + git add . + git commit -m "Add descriptive commit message" + ``` + Write clear, concise commit messages that explain what your changes do and why. + +4. **Keep your branch up to date**: + Regularly sync your branch with the latest changes from the `dev` branch to avoid conflicts. + +5. **Push to your fork**: + ```bash + git push origin feature/{your-feature-name} + ``` + +### Pull Requests + +1. **Create a pull request**: Go to the original repository and click "New Pull Request." Select your feature branch and set the base branch to `dev`. + +2. **Write a detailed description**: Your PR should include: + - A clear title summarizing the changes + - A detailed description of what was changed and why + - Reference to any related issues (e.g., "Fixes #123" or "Relates to #456") + - Screenshots or screen recordings for UI changes + +3. **Respond to feedback**: Maintainers may request changes. + +4. **Be patient**: Reviews take time. Maintainers will get to your PR as soon as they can. + + + +## Reporting Bugs + +When reporting bugs, please include: + +- A clear, descriptive title +- Steps to reproduce the issue +- Expected behavior vs. actual behavior +- Screenshots or error messages if applicable +- Your environment details (OS version, app version, etc.) + +## Feature Requests + +Feature requests are welcome! Please: + +- Check if the feature has already been requested +- Clearly describe the feature and its use case +- Explain why this feature would be valuable to users +- Be open to discussion and alternative approaches + +## Getting Help + +If you need help or have questions: + +- Check the project documentation +- Search existing issues for similar questions +- Open a new issue with the "question" label +- Join our [community Discord server](https://discord.com/servers/boring-notch-1269588937320566815) + +--- + +Thank you for contributing to Boring Notch! Your efforts help make this project better for everyone. 🎉 diff --git a/Configuration/dmg/.background/background.tiff b/Configuration/dmg/.background/background.tiff new file mode 100644 index 00000000..f532a06e Binary files /dev/null and b/Configuration/dmg/.background/background.tiff differ diff --git a/Configuration/dmg/create_dmg.sh b/Configuration/dmg/create_dmg.sh new file mode 100755 index 00000000..dcfd4346 --- /dev/null +++ b/Configuration/dmg/create_dmg.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Minimal wrapper to create a DMG using dmgbuild. +# Usage: ./create_dmg.sh + +APP_PATH="${1:?App path required}" +DMG_OUTPUT="${2:?DMG output path required}" +VOLUME_NAME="${3:?Volume name required}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SETTINGS="$SCRIPT_DIR/dmgbuild_settings.py" + +BACKGROUND_DIR="$SCRIPT_DIR/.background" + +die() { + echo "Error: $*" >&2 + exit 1 +} + +abs_path() { + python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$1" +} + +ensure_dmgbuild_and_badge_support() { + if command -v dmgbuild >/dev/null 2>&1; then + return 0 + fi + + if ! command -v pip3 >/dev/null 2>&1; then + die "dmgbuild is not installed and pip3 is not available. Please install dmgbuild." + fi + + echo "dmgbuild not found — installing via pip3 (user scope)..." + python3 -m pip install --user "dmgbuild[badge_icons]" || python3 -m pip install "dmgbuild[badge_icons]" + USER_BIN="$(python3 -c 'import site,sys; print(site.getuserbase() + "/bin")')" + export PATH="$USER_BIN:$PATH" +} + +find_app_icns() { + local app="$1" + local info_plist="$app/Contents/Info.plist" + + if [ ! -f "$info_plist" ]; then + return 1 + fi + + local icon_file="" + icon_file="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$info_plist" 2>/dev/null || true)" + if [ -z "$icon_file" ]; then + icon_file="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' "$info_plist" 2>/dev/null || true)" + fi + + if [ -n "$icon_file" ]; then + if [[ "$icon_file" != *.icns ]]; then + icon_file="$icon_file.icns" + fi + if [ -f "$app/Contents/Resources/$icon_file" ]; then + echo "$app/Contents/Resources/$icon_file" + return 0 + fi + fi + + # Fallback: any .icns inside the app bundle + local candidate + candidate="$(find "$app/Contents/Resources" -maxdepth 1 -name '*.icns' -print -quit 2>/dev/null || true)" + if [ -n "$candidate" ] && [ -f "$candidate" ]; then + echo "$candidate" + return 0 + fi + + return 1 +} + +if [ ! -f "$SETTINGS" ]; then + die "dmgbuild settings not found: $SETTINGS" +fi + +ensure_dmgbuild_and_badge_support + +export DMG_APP_PATH="$(abs_path "$APP_PATH")" +export DMG_VOLUME_NAME="$VOLUME_NAME" + +BACKGROUND_TIFF="$BACKGROUND_DIR/background.tiff" + +export DMG_BACKGROUND="$(abs_path "$BACKGROUND_TIFF")" + +# Badge icon: use the app's icon for badging the volume icon +if DMG_ICON_ICNS="$(find_app_icns "$DMG_APP_PATH" 2>/dev/null)"; then + export DMG_BADGE_ICON="$(abs_path "$DMG_ICON_ICNS")" + echo "Using badge icon for DMG volume." +else + echo "No app icon found, skipping badge." +fi + +echo "Creating DMG via dmgbuild: app=$DMG_APP_PATH output=$DMG_OUTPUT volume=$DMG_VOLUME_NAME" + +# Validate inputs early to give clearer errors for common typos +if [ ! -e "$DMG_APP_PATH" ]; then + echo "Error: App path not found: $DMG_APP_PATH" >&2 + echo "Make sure you passed the correct .app path (e.g. Release/boringNotch.app)" >&2 + exit 2 +fi + +if [ ! -d "$DMG_APP_PATH" ]; then + echo "Error: App path exists but is not a directory: $DMG_APP_PATH" >&2 + exit 3 +fi + +dmgbuild -s "$SETTINGS" "$DMG_VOLUME_NAME" "$DMG_OUTPUT" + +exit $? diff --git a/Configuration/dmg/dmgbuild_settings.py b/Configuration/dmg/dmgbuild_settings.py new file mode 100644 index 00000000..97799c72 --- /dev/null +++ b/Configuration/dmg/dmgbuild_settings.py @@ -0,0 +1,51 @@ +import os + +# dmgbuild settings file. This is read by the `dmgbuild` CLI (or Python API). +# It uses environment variables exported by the shell wrapper script: +# - DMG_APP_PATH: path to the .app bundle to put in the DMG +# - DMG_VOLUME_NAME: volume name to display when the DMG is mounted +# - DMG_BACKGROUND: absolute path to the background image to use + +APP_PATH = os.environ.get('DMG_APP_PATH') +VOLUME_NAME = os.environ.get('DMG_VOLUME_NAME', 'boringNotch') +BACKGROUND = os.environ.get('DMG_BACKGROUND', '') +BADGE_ICON = os.environ.get('DMG_BADGE_ICON', '') + +# If DMG_BACKGROUND not provided, default to the hiDPI TIFF in .background. +if not BACKGROUND: + base = os.path.join(os.path.dirname(__file__), '.background', 'background.tiff') + +# Basic DMG metadata +volume_name = VOLUME_NAME +format = 'UDZO' +compression_level = 9 + +# Files and symlinks to include in the DMG +files = [APP_PATH] if APP_PATH else [] +symlinks = {'Applications': '/Applications'} + +# Background image path (dmgbuild will copy this file into the DMG's .background) +background = BACKGROUND + + +# Window rectangle: ((left, top), (right, bottom)) +window_rect = ((0, 0), (660, 400)) + +# Icon size (points) +icon_size = 128 + +# Icon locations: map filename (or bundle name) -> (x, y) in window coords +app_basename = os.path.basename(APP_PATH) if APP_PATH else 'boringNotch.app' +icon_locations = { + app_basename: (150, 180), + 'Applications': (510, 180), +} + +# Misc Finder options +show_statusbar = False +show_tabview = False +show_toolbar = False + +# Optionally set a custom icon for the DMG volume (leave empty to skip) +if BADGE_ICON and os.path.exists(BADGE_ICON): + badge_icon = BADGE_ICON diff --git a/README.md b/README.md index 928e89ec..964129be 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@


- Boring Notch + Boring Notch
Boring Notch
@@ -13,7 +13,7 @@ Discord Badge - + Ko-Fi

@@ -126,33 +126,7 @@ brew install --cask TheBoredTeam/boring-notch/boring-notch --no-quarantine ## 🤝 Contributing -We’re all about good vibes and awesome contributions! Here’s how you can join the fun: - -1. **Fork the Repo**: Click that shiny "Fork" button and make your own version. -2. **Clone Your Fork**: - ```bash - git clone https://github.com/{your-name}/boring.notch.git - # Replace {your-name} with your GitHub username - ``` -3. **Make sure to use the `dev` branch as base.** -4. **Create a New Branch**: - ```bash - git checkout -b feature/{your-feature-name} - # Replace {your-feature-name} with a descriptive and concise name for your branch - # It is best practice to use only alphanumeric characters, write words in lowercase - # and seperate words with a single hyphen - ``` -5. **Make Your Changes**: Add that feature or fix that bug. -6. **Commit Your Changes**: - ```bash - git commit -m "insert descriptive message here" - ``` -7. **Push to Your Fork**: - ```bash - git push origin feature/{your-feature-name} - # Remember to replace {your-feature-name} with the name you chose - ``` -8. **Create a Pull Request**: Head to the original repository and click on "New Pull Request." Fill in the required details, **make sure the base branch is set to `dev`**, and submit your PR. Let’s see what you’ve got! +We’re all about good vibes and awesome contributions! Read [CONTRIBUTING.md](CONTRIBUTING.md) to learn how you can join the fun! ## Join our Discord Server diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 7b3413a0..7a006bbb 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -66,18 +66,20 @@ 11CFC6632E09918400748C80 /* MusicControllerSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CFC6622E09917B00748C80 /* MusicControllerSelectionView.swift */; }; 11CFC6652E09C7B300748C80 /* OnboardingFinishView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CFC6642E09C7B300748C80 /* OnboardingFinishView.swift */; }; 11D58EA22E760AE100FA8377 /* ImageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11D58EA12E760AE100FA8377 /* ImageService.swift */; }; - 11DB26662EDD0BE1001EA0CF /* LyricsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26652EDD0BE1001EA0CF /* LyricsService.swift */; }; - 11DB26732EDD0CDF001EA0CF /* ShortcutsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */; }; - 11DB26742EDD0CDF001EA0CF /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266C2EDD0CDF001EA0CF /* GeneralSettingsView.swift */; }; - 11DB26752EDD0CDF001EA0CF /* ShelfSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */; }; - 11DB26762EDD0CDF001EA0CF /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26692EDD0CDF001EA0CF /* AppearanceSettingsView.swift */; }; - 11DB26772EDD0CDF001EA0CF /* SettingsHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */; }; - 11DB26782EDD0CDF001EA0CF /* BatterySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266A2EDD0CDF001EA0CF /* BatterySettingsView.swift */; }; - 11DB26792EDD0CDF001EA0CF /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26682EDD0CDF001EA0CF /* AdvancedSettingsView.swift */; }; - 11DB267A2EDD0CDF001EA0CF /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26672EDD0CDF001EA0CF /* AboutView.swift */; }; - 11DB267B2EDD0CDF001EA0CF /* CalendarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266B2EDD0CDF001EA0CF /* CalendarSettingsView.swift */; }; - 11DB267C2EDD0CDF001EA0CF /* HUDSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266D2EDD0CDF001EA0CF /* HUDSettingsView.swift */; }; - 11DB267D2EDD0CDF001EA0CF /* MediaSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */; }; + 11DB26662EDD0BE1001EA0CF /* LyricsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26652EDD0BE1001EA0CF /* LyricsService.swift */; }; + 11DB26732EDD0CDF001EA0CF /* ShortcutsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */; }; + 11DB26742EDD0CDF001EA0CF /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266C2EDD0CDF001EA0CF /* GeneralSettingsView.swift */; }; + 11DB26752EDD0CDF001EA0CF /* ShelfSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */; }; + 11DB26762EDD0CDF001EA0CF /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26692EDD0CDF001EA0CF /* AppearanceSettingsView.swift */; }; + 11DB26772EDD0CDF001EA0CF /* SettingsHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */; }; + 11DB26782EDD0CDF001EA0CF /* BatterySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266A2EDD0CDF001EA0CF /* BatterySettingsView.swift */; }; + 11DB26792EDD0CDF001EA0CF /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26682EDD0CDF001EA0CF /* AdvancedSettingsView.swift */; }; + 11DB267A2EDD0CDF001EA0CF /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB26672EDD0CDF001EA0CF /* AboutView.swift */; }; + 11DB267B2EDD0CDF001EA0CF /* CalendarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266B2EDD0CDF001EA0CF /* CalendarSettingsView.swift */; }; + 11DB267C2EDD0CDF001EA0CF /* HUDSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266D2EDD0CDF001EA0CF /* HUDSettingsView.swift */; }; + 11DB267D2EDD0CDF001EA0CF /* MediaSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */; }; + E1A200022F01000100ABCDEF /* WellnessSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A200012F01000100ABCDEF /* WellnessSettingsView.swift */; }; + E1A100022F00000100ABCDEF /* EyeBreakReminderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A100012F00000100ABCDEF /* EyeBreakReminderManager.swift */; }; 11EFCD702E8E92D600D0B974 /* ShelfItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11EFCD6F2E8E92D600D0B974 /* ShelfItemViewModel.swift */; }; 11F747CE2EC75CEA00F841DB /* DragPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F747CD2EC75CEA00F841DB /* DragPreviewView.swift */; }; 11F7485B2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 11F7484F2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -250,11 +252,12 @@ 11DB266A2EDD0CDF001EA0CF /* BatterySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatterySettingsView.swift; sourceTree = ""; }; 11DB266B2EDD0CDF001EA0CF /* CalendarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSettingsView.swift; sourceTree = ""; }; 11DB266C2EDD0CDF001EA0CF /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; - 11DB266D2EDD0CDF001EA0CF /* HUDSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUDSettingsView.swift; sourceTree = ""; }; - 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSettingsView.swift; sourceTree = ""; }; - 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHelpers.swift; sourceTree = ""; }; - 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfSettingsView.swift; sourceTree = ""; }; - 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsSettingsView.swift; sourceTree = ""; }; + 11DB266D2EDD0CDF001EA0CF /* HUDSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUDSettingsView.swift; sourceTree = ""; }; + 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSettingsView.swift; sourceTree = ""; }; + 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHelpers.swift; sourceTree = ""; }; + 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfSettingsView.swift; sourceTree = ""; }; + 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsSettingsView.swift; sourceTree = ""; }; + E1A200012F01000100ABCDEF /* WellnessSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WellnessSettingsView.swift; sourceTree = ""; }; 11EFCD6F2E8E92D600D0B974 /* ShelfItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShelfItemViewModel.swift; sourceTree = ""; }; 11F747CD2EC75CEA00F841DB /* DragPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragPreviewView.swift; sourceTree = ""; }; 11F7484F2EC9AABA00F841DB /* BoringNotchXPCHelper.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = BoringNotchXPCHelper.xpc; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -326,6 +329,7 @@ B1D6FD422C6603730015F173 /* SoftwareUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftwareUpdater.swift; sourceTree = ""; }; B1F0A0012E60000100000001 /* BrightnessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessManager.swift; sourceTree = ""; }; B1FEB4982C7686630066EBBC /* PanGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanGesture.swift; sourceTree = ""; }; + E1A100012F00000100ABCDEF /* EyeBreakReminderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EyeBreakReminderManager.swift; sourceTree = ""; }; F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActivityManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -472,12 +476,13 @@ 11DB266C2EDD0CDF001EA0CF /* GeneralSettingsView.swift */, 11DB266D2EDD0CDF001EA0CF /* HUDSettingsView.swift */, 11DB266E2EDD0CDF001EA0CF /* MediaSettingsView.swift */, - 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */, - 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */, - 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */, - ); - path = Views; - sourceTree = ""; + 11DB266F2EDD0CDF001EA0CF /* SettingsHelpers.swift */, + 11DB26702EDD0CDF001EA0CF /* ShelfSettingsView.swift */, + 11DB26712EDD0CDF001EA0CF /* ShortcutsSettingsView.swift */, + E1A200012F01000100ABCDEF /* WellnessSettingsView.swift */, + ); + path = Views; + sourceTree = ""; }; 11F748672EC9AC9600F841DB /* XPCHelperClient */ = { isa = PBXGroup; @@ -546,6 +551,7 @@ children = ( 11DB26652EDD0BE1001EA0CF /* LyricsService.swift */, 11D58EA12E760AE100FA8377 /* ImageService.swift */, + E1A100012F00000100ABCDEF /* EyeBreakReminderManager.swift */, F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */, 112FB7342CCF16F70015238C /* NotchSpaceManager.swift */, 147163992C5D35FF0068B555 /* MusicManager.swift */, @@ -957,6 +963,7 @@ B1D365D02C6A9A6C0047BDBC /* SystemEventIndicatorModifier.swift in Sources */, 1194E87C2EA19E09009C82D6 /* ImageProcessingService.swift in Sources */, 1194E8872EA6DDA7009C82D6 /* BoringNotchSkyLightWindow.swift in Sources */, + E1A100022F00000100ABCDEF /* EyeBreakReminderManager.swift in Sources */, 14D570CB2C5F4B2C0011E668 /* BatteryStatusViewModel.swift in Sources */, 9A0887322C7A693000C160EA /* TabButton.swift in Sources */, 1153BD9C2D98853B00979FB0 /* NowPlayingController.swift in Sources */, @@ -1013,11 +1020,12 @@ 11DB26772EDD0CDF001EA0CF /* SettingsHelpers.swift in Sources */, 11DB26782EDD0CDF001EA0CF /* BatterySettingsView.swift in Sources */, 11DB26792EDD0CDF001EA0CF /* AdvancedSettingsView.swift in Sources */, - 11DB267A2EDD0CDF001EA0CF /* AboutView.swift in Sources */, - 11DB267B2EDD0CDF001EA0CF /* CalendarSettingsView.swift in Sources */, - 11DB267C2EDD0CDF001EA0CF /* HUDSettingsView.swift in Sources */, - 11DB267D2EDD0CDF001EA0CF /* MediaSettingsView.swift in Sources */, - 14D570C62C5F38210011E668 /* BoringHeader.swift in Sources */, + 11DB267A2EDD0CDF001EA0CF /* AboutView.swift in Sources */, + 11DB267B2EDD0CDF001EA0CF /* CalendarSettingsView.swift in Sources */, + 11DB267C2EDD0CDF001EA0CF /* HUDSettingsView.swift in Sources */, + 11DB267D2EDD0CDF001EA0CF /* MediaSettingsView.swift in Sources */, + E1A200022F01000100ABCDEF /* WellnessSettingsView.swift in Sources */, + 14D570C62C5F38210011E668 /* BoringHeader.swift in Sources */, B17266E32C65F7FB0031BA0D /* WhatsNewView.swift in Sources */, 14C08BB62C8DE42D000F8AA0 /* CalendarManager.swift in Sources */, 14D570CD2C5F4BB70011E668 /* BoringBattery.swift in Sources */, diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index b8e8e235..fcaea0ad 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -23,9 +23,13 @@ struct ContentView: View { @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var brightnessManager = BrightnessManager.shared @ObservedObject var volumeManager = VolumeManager.shared + @ObservedObject var eyeBreakReminder = EyeBreakReminderManager.shared @State private var hoverTask: Task? @State private var isHovering: Bool = false @State private var anyDropDebounceTask: Task? + @State private var didAutoOpenForEyeBreak: Bool = false + @State private var isClosingEyeBreakPanel: Bool = false + @State private var eyeBreakPanelSnapshot: EyeBreakBannerState? @State private var gestureProgress: CGFloat = .zero @@ -170,13 +174,13 @@ struct ContentView: View { } } .onReceive(NotificationCenter.default.publisher(for: .sharingDidFinish)) { _ in - if vm.notchState == .open && !isHovering && !vm.isBatteryPopoverActive { + if vm.notchState == .open && !isHovering && !vm.isBatteryPopoverActive && eyeBreakReminder.banner == nil { hoverTask?.cancel() hoverTask = Task { try? await Task.sleep(for: .milliseconds(100)) guard !Task.isCancelled else { return } await MainActor.run { - if self.vm.notchState == .open && !self.isHovering && !self.vm.isBatteryPopoverActive && !SharingStateManager.shared.preventNotchClose { + if self.vm.notchState == .open && !self.isHovering && !self.vm.isBatteryPopoverActive && self.eyeBreakReminder.banner == nil && !SharingStateManager.shared.preventNotchClose { self.vm.close() } } @@ -191,13 +195,13 @@ struct ContentView: View { } } .onChange(of: vm.isBatteryPopoverActive) { - if !vm.isBatteryPopoverActive && !isHovering && vm.notchState == .open && !SharingStateManager.shared.preventNotchClose { + if !vm.isBatteryPopoverActive && !isHovering && vm.notchState == .open && eyeBreakReminder.banner == nil && !SharingStateManager.shared.preventNotchClose { hoverTask?.cancel() hoverTask = Task { try? await Task.sleep(for: .milliseconds(100)) guard !Task.isCancelled else { return } await MainActor.run { - if !self.vm.isBatteryPopoverActive && !self.isHovering && self.vm.notchState == .open && !SharingStateManager.shared.preventNotchClose { + if !self.vm.isBatteryPopoverActive && !self.isHovering && self.vm.notchState == .open && self.eyeBreakReminder.banner == nil && !SharingStateManager.shared.preventNotchClose { self.vm.close() } } @@ -235,6 +239,38 @@ struct ContentView: View { .background(dragDetector) .preferredColorScheme(.dark) .environmentObject(vm) + .onChange(of: eyeBreakReminder.banner != nil) { _, hasBanner in + if hasBanner { + eyeBreakPanelSnapshot = eyeBreakReminder.banner + isClosingEyeBreakPanel = false + guard vm.notchState == .closed else { return } + didAutoOpenForEyeBreak = true + withAnimation(animationSpring) { + vm.open() + } + } else if didAutoOpenForEyeBreak { + didAutoOpenForEyeBreak = false + guard vm.notchState == .open else { return } + guard !SharingStateManager.shared.preventNotchClose else { return } + if eyeBreakPanelSnapshot == nil { + eyeBreakPanelSnapshot = EyeBreakBannerState( + remainingSeconds: 0, + breakDurationSeconds: 1, + snoozeMinutes: Defaults[.eyeBreakSnoozeMinutes] + ) + } + isClosingEyeBreakPanel = true + withAnimation(animationSpring) { + vm.close() + } + } + } + .onChange(of: vm.notchState) { _, newState in + if newState == .closed { + isClosingEyeBreakPanel = false + eyeBreakPanelSnapshot = nil + } + } .onChange(of: vm.anyDropZoneTargeting) { _, isTargeted in anyDropDebounceTask?.cancel() @@ -369,11 +405,19 @@ struct ContentView: View { .zIndex(1) if vm.notchState == .open { VStack { - switch coordinator.currentView { - case .home: - NotchHomeView(albumArtNamespace: albumArtNamespace) - case .shelf: - ShelfView() + if let banner = currentEyeBreakPanelState { + EyeBreakReminderPanel( + banner: banner, + onSkip: { handleEyeBreakAction { eyeBreakReminder.skipBreak() } }, + onSnooze: { handleEyeBreakAction { eyeBreakReminder.snoozeBreak() } } + ) + } else { + switch coordinator.currentView { + case .home: + NotchHomeView(albumArtNamespace: albumArtNamespace) + case .shelf: + ShelfView() + } } } .transition( @@ -533,6 +577,29 @@ struct ContentView: View { } } + private func handleEyeBreakAction(_ action: () -> Void) { + eyeBreakPanelSnapshot = eyeBreakReminder.banner + isClosingEyeBreakPanel = true + action() + didAutoOpenForEyeBreak = false + guard vm.notchState == .open else { return } + guard !SharingStateManager.shared.preventNotchClose else { return } + withAnimation(animationSpring) { + isHovering = false + vm.close() + } + } + + private var currentEyeBreakPanelState: EyeBreakBannerState? { + if let liveBanner = eyeBreakReminder.banner { + return liveBanner + } + if isClosingEyeBreakPanel { + return eyeBreakPanelSnapshot + } + return nil + } + // MARK: - Hover Management private func handleHover(_ hovering: Bool) { @@ -574,7 +641,7 @@ struct ContentView: View { self.isHovering = false } - if self.vm.notchState == .open && !self.vm.isBatteryPopoverActive && !SharingStateManager.shared.preventNotchClose { + if self.vm.notchState == .open && !self.vm.isBatteryPopoverActive && self.eyeBreakReminder.banner == nil && !SharingStateManager.shared.preventNotchClose { self.vm.close() } } @@ -636,6 +703,84 @@ struct ContentView: View { } } +private struct EyeBreakReminderPanel: View { + let banner: EyeBreakBannerState + let onSkip: () -> Void + let onSnooze: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Label("Eye break", systemImage: "eye") + .font(.headline) + .foregroundStyle(.white) + Spacer(minLength: 8) + Text(countdownText) + .font(.system(size: 26, weight: .bold, design: .monospaced)) + .foregroundStyle(.white.opacity(0.95)) + } + + ProgressView(value: progressValue) + .tint(.white.opacity(0.9)) + .controlSize(.small) + + HStack(spacing: 10) { + Button("Skip", action: onSkip) + .buttonStyle(EyeBreakReminderButtonStyle(variant: .secondary)) + Spacer() + Button("Snooze \(banner.snoozeMinutes)m", action: onSnooze) + .buttonStyle(EyeBreakReminderButtonStyle(variant: .primary)) + } + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(Color.black.opacity(0.88)) + ) + .padding(.horizontal, 12) + .padding(.bottom, 12) + .shadow(color: .black.opacity(0.28), radius: 8, y: 3) + .animation(.smooth, value: banner.remainingSeconds) + } + + private var countdownText: String { + let clamped = max(0, banner.remainingSeconds) + let minutes = clamped / 60 + let seconds = clamped % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + private var progressValue: Double { + guard banner.breakDurationSeconds > 0 else { return 0 } + let completed = banner.breakDurationSeconds - max(0, banner.remainingSeconds) + return Double(completed) / Double(banner.breakDurationSeconds) + } +} + +private struct EyeBreakReminderButtonStyle: ButtonStyle { + enum Variant { + case primary + case secondary + } + + let variant: Variant + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white.opacity(variant == .primary ? 1.0 : 0.9)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule() + .fill(variant == .primary ? Color.white.opacity(0.22) : Color.white.opacity(0.12)) + ) + .scaleEffect(configuration.isPressed ? 0.97 : 1.0) + .animation(.smooth(duration: 0.12), value: configuration.isPressed) + } +} + struct FullScreenDropDelegate: DropDelegate { @Binding var isTargeted: Bool let onDrop: () -> Void diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index 59470ffa..63a80e77 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -901,6 +901,12 @@ } } } + }, + "%lld min" : { + + }, + "%lld sec" : { + }, "%lld%%" : { "localizations" : { @@ -1701,6 +1707,12 @@ } } } + }, + "Active interval" : { + + }, + "Active timer" : { + }, "Add" : { "extractionState" : "stale", @@ -3103,6 +3115,9 @@ } } } + }, + "Available only in DEBUG builds." : { + }, "Battery" : { "localizations" : { @@ -3803,6 +3818,9 @@ } } } + }, + "Break countdown" : { + }, "Calendar" : { "localizations" : { @@ -6004,6 +6022,9 @@ } } } + }, + "Counting time" : { + }, "Currently selected: %@" : { "localizations" : { @@ -6604,6 +6625,9 @@ } } } + }, + "Debug" : { + }, "Default" : { "localizations" : { @@ -7508,6 +7532,9 @@ } } } + }, + "Due pending" : { + }, "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" : { "localizations" : { @@ -7908,6 +7935,9 @@ } } } + }, + "Enable eye-break reminders" : { + }, "Enable gestures" : { "localizations" : { @@ -8909,6 +8939,9 @@ } } } + }, + "Eye break" : { + }, "Files dropped on the shelf will be shared via this service" : { "localizations" : { @@ -13917,6 +13950,9 @@ } } } + }, + "No" : { + }, "No events" : { "localizations" : { @@ -14219,7 +14255,7 @@ } } }, - "Normalize scroll/gesture direction" : { + "Normalize gesture direction" : { }, "Not Now" : { @@ -15321,6 +15357,9 @@ } } } + }, + "Pause media when reminder appears" : { + }, "Pick a Color" : { "localizations" : { @@ -15421,6 +15460,9 @@ } } } + }, + "Play sound for reminder and completion" : { + }, "Plugged In" : { "localizations" : { @@ -16421,6 +16463,9 @@ } } } + }, + "Reminder settings" : { + }, "Reminders" : { "localizations" : { @@ -16921,6 +16966,9 @@ } } } + }, + "Reset" : { + }, "Reset to Defaults" : { "localizations" : { @@ -19495,6 +19543,9 @@ } } } + }, + "Skip" : { + }, "Slider color" : { "localizations" : { @@ -19795,6 +19846,12 @@ } } } + }, + "Snooze %lldm" : { + + }, + "Snooze duration" : { + }, "Software updates" : { "localizations" : { @@ -20797,6 +20854,9 @@ } } } + }, + "Trigger now" : { + }, "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" : { "localizations" : { @@ -21498,6 +21558,9 @@ } } } + }, + "Uses the 20-20-20 rule to reduce eye strain: every 20 minutes, look 20 feet away for 20 seconds." : { + }, "Using System Accent" : { "localizations" : { @@ -21998,6 +22061,9 @@ } } } + }, + "Wellness" : { + }, "What's New" : { "localizations" : { @@ -22298,6 +22364,9 @@ } } } + }, + "Yes" : { + }, "You can now enjoy the app. If you want to tweak things further, you can always visit the settings." : { "localizations" : { diff --git a/boringNotch/components/Settings/SettingsView.swift b/boringNotch/components/Settings/SettingsView.swift index 811879d9..7eb29529 100644 --- a/boringNotch/components/Settings/SettingsView.swift +++ b/boringNotch/components/Settings/SettingsView.swift @@ -40,6 +40,9 @@ struct SettingsView: View { NavigationLink(value: "Battery") { Label("Battery", systemImage: "battery.100.bolt") } + NavigationLink(value: "Wellness") { + Label("Wellness", systemImage: "eye") + } NavigationLink(value: "Shelf") { Label("Shelf", systemImage: "books.vertical") } @@ -72,6 +75,8 @@ struct SettingsView: View { HUD() case "Battery": Charge() + case "Wellness": + WellnessSettings() case "Shelf": Shelf() case "Shortcuts": diff --git a/boringNotch/components/Settings/Views/WellnessSettingsView.swift b/boringNotch/components/Settings/Views/WellnessSettingsView.swift new file mode 100644 index 00000000..d758699b --- /dev/null +++ b/boringNotch/components/Settings/Views/WellnessSettingsView.swift @@ -0,0 +1,126 @@ +// +// WellnessSettingsView.swift +// boringNotch +// +// Created by Richard Kunkli on 07/08/2024. +// + +import Defaults +import SwiftUI + +struct WellnessSettings: View { + @Default(.eyeBreakEnabled) var eyeBreakEnabled + @Default(.eyeBreakIntervalMinutes) var eyeBreakIntervalMinutes + @Default(.eyeBreakDurationSeconds) var eyeBreakDurationSeconds + @Default(.eyeBreakSnoozeMinutes) var eyeBreakSnoozeMinutes +#if DEBUG + @ObservedObject var eyeBreakReminder = EyeBreakReminderManager.shared +#endif + + var body: some View { + Form { + Section { + Defaults.Toggle(key: .eyeBreakEnabled) { + Text("Enable eye-break reminders") + } + } footer: { + Text("Uses the 20-20-20 rule to reduce eye strain: every 20 minutes, look 20 feet away for 20 seconds.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + Stepper(value: $eyeBreakIntervalMinutes, in: 5...120, step: 1) { + HStack { + Text("Active interval") + Spacer() + Text("\(eyeBreakIntervalMinutes) min") + .foregroundStyle(.secondary) + } + } + + Stepper(value: $eyeBreakDurationSeconds, in: 5...120, step: 1) { + HStack { + Text("Break countdown") + Spacer() + Text("\(eyeBreakDurationSeconds) sec") + .foregroundStyle(.secondary) + } + } + + Stepper(value: $eyeBreakSnoozeMinutes, in: 1...60, step: 1) { + HStack { + Text("Snooze duration") + Spacer() + Text("\(eyeBreakSnoozeMinutes) min") + .foregroundStyle(.secondary) + } + } + + Defaults.Toggle(key: .eyeBreakSoundEnabled) { + Text("Play sound for reminder and completion") + } + + Defaults.Toggle(key: .eyeBreakPauseMediaOnPopup) { + Text("Pause media when reminder appears") + } + } header: { + Text("Reminder settings") + } + .disabled(!eyeBreakEnabled) + +#if DEBUG + Section { + HStack { + Text("Active timer") + Spacer() + Text(formattedDuration(eyeBreakReminder.debugState.activeSecondsAccumulated)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + HStack { + Text("Counting time") + Spacer() + Text(eyeBreakReminder.debugState.isAccruingActiveTime ? "Yes" : "No") + .foregroundStyle(.secondary) + } + HStack { + Text("Due pending") + Spacer() + Text(eyeBreakReminder.debugState.duePending ? "Yes" : "No") + .foregroundStyle(.secondary) + } + HStack(spacing: 10) { + Button("Trigger now") { + eyeBreakReminder.debugTriggerReminderNow() + } + .buttonStyle(.borderedProminent) + + Button("Reset") { + eyeBreakReminder.debugResetState() + } + .buttonStyle(.bordered) + } + } header: { + Text("Debug") + } footer: { + Text("Available only in DEBUG builds.") + .font(.caption) + .foregroundStyle(.secondary) + } +#endif + } + .accentColor(.effectiveAccent) + .navigationTitle("Wellness") + } + +#if DEBUG + private func formattedDuration(_ seconds: Int) -> String { + let clamped = max(0, seconds) + let hours = clamped / 3600 + let minutes = (clamped % 3600) / 60 + let remaining = clamped % 60 + return String(format: "%02d:%02d:%02d", hours, minutes, remaining) + } +#endif +} diff --git a/boringNotch/managers/EyeBreakReminderManager.swift b/boringNotch/managers/EyeBreakReminderManager.swift new file mode 100644 index 00000000..9f725bd8 --- /dev/null +++ b/boringNotch/managers/EyeBreakReminderManager.swift @@ -0,0 +1,429 @@ +import AppKit +import Combine +import Defaults +import Foundation + +struct EyeBreakBannerState: Equatable { + var remainingSeconds: Int + var breakDurationSeconds: Int + var snoozeMinutes: Int +} + +struct EyeBreakDebugState: Equatable { + var activeSecondsAccumulated: Int = 0 + var duePending: Bool = false + var snoozeUntil: Date? = nil + var isSystemSleeping: Bool = false + var isDisplaySleeping: Bool = false + var isSessionActive: Bool = true + var isAccruingActiveTime: Bool = false + var bannerVisible: Bool = false + var bannerRemainingSeconds: Int? = nil +} + +@MainActor +final class EyeBreakReminderManager: ObservableObject { + static let shared = EyeBreakReminderManager() + + @Published private(set) var banner: EyeBreakBannerState? + @Published private(set) var debugState: EyeBreakDebugState = .init() + + private var activeSecondsAccumulated: TimeInterval = 0 + private var duePending: Bool = false + private var snoozeUntil: Date? + private var debugIgnoreActivityUntilDismiss: Bool = false + private var lastMonitorDate: Date? + private var didPauseMediaForReminder: Bool = false + + private var isSystemSleeping: Bool = false + private var isDisplaySleeping: Bool = false + private var isSessionActive: Bool = true + + private var monitorTask: Task? + private var countdownTask: Task? + private var mediaCommandTask: Task? + + private var willSleepObserver: Any? + private var didWakeObserver: Any? + private var screensDidSleepObserver: Any? + private var screensDidWakeObserver: Any? + private var sessionResignObserver: Any? + private var sessionBecomeObserver: Any? + private var cancellables = Set() + + private let accrualTickSeconds: TimeInterval = 5 + + private init() { + setupSystemStateObservers() + setupDefaultsObservers() + startMonitoringIfNeeded() + updateDebugState() + } + + deinit { + monitorTask?.cancel() + countdownTask?.cancel() + mediaCommandTask?.cancel() + + let workspaceCenter = NSWorkspace.shared.notificationCenter + if let observer = willSleepObserver { + workspaceCenter.removeObserver(observer) + } + if let observer = didWakeObserver { + workspaceCenter.removeObserver(observer) + } + if let observer = screensDidSleepObserver { + workspaceCenter.removeObserver(observer) + } + if let observer = screensDidWakeObserver { + workspaceCenter.removeObserver(observer) + } + if let observer = sessionResignObserver { + workspaceCenter.removeObserver(observer) + } + if let observer = sessionBecomeObserver { + workspaceCenter.removeObserver(observer) + } + + cancellables.removeAll() + } + + func completeBreak() { + guard banner != nil else { return } + resetCycle() + } + + func skipBreak() { + guard banner != nil else { return } + resetCycle() + } + + func snoozeBreak() { + guard banner != nil else { return } + + dismissBanner() + resumeMediaIfNeeded() + duePending = true + snoozeUntil = Date().addingTimeInterval(TimeInterval(validSnoozeMinutes() * 60)) + updateDebugState() + } + + func debugTriggerReminderNow() { + snoozeUntil = nil + duePending = true + debugIgnoreActivityUntilDismiss = true + presentBreakBanner(force: true) + updateDebugState() + } + + func debugResetState() { + clearAllState() + } + + private func setupDefaultsObservers() { + Defaults.publisher(.eyeBreakEnabled) + .receive(on: RunLoop.main) + .sink { [weak self] change in + Task { @MainActor [weak self] in + guard let self = self else { return } + if !change.newValue { + self.clearAllState() + } + } + } + .store(in: &cancellables) + } + + private func setupSystemStateObservers() { + let workspaceCenter = NSWorkspace.shared.notificationCenter + + willSleepObserver = workspaceCenter.addObserver( + forName: NSWorkspace.willSleepNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self = self else { return } + self.isSystemSleeping = true + self.handleNonAccruingTransition() + } + } + + didWakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.isSystemSleeping = false + } + } + + screensDidSleepObserver = workspaceCenter.addObserver( + forName: NSWorkspace.screensDidSleepNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self = self else { return } + self.isDisplaySleeping = true + self.handleNonAccruingTransition() + } + } + + screensDidWakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.screensDidWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.isDisplaySleeping = false + } + } + + sessionResignObserver = workspaceCenter.addObserver( + forName: NSWorkspace.sessionDidResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self = self else { return } + self.isSessionActive = false + self.handleNonAccruingTransition() + } + } + + sessionBecomeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.sessionDidBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.isSessionActive = true + } + } + } + + private func startMonitoringIfNeeded() { + guard monitorTask == nil else { return } + + monitorTask = Task { @MainActor [weak self] in + guard let self = self else { return } + + while !Task.isCancelled { + self.monitorTick() + try? await Task.sleep(for: .seconds(self.accrualTickSeconds)) + } + } + } + + private func monitorTick() { + let now = Date() + let previousTick = lastMonitorDate ?? now + let elapsedSinceLastTick = max(0, now.timeIntervalSince(previousTick)) + let clampedElapsed = min(elapsedSinceLastTick, accrualTickSeconds * 3) + + defer { updateDebugState() } + defer { lastMonitorDate = now } + + guard Defaults[.eyeBreakEnabled] else { + clearAllState() + return + } + + if banner != nil { + if !debugIgnoreActivityUntilDismiss && !canCountActiveTimeNow() { + resetCycle() + } + return + } + + if let snoozeUntil, Date() < snoozeUntil { + return + } + + if snoozeUntil != nil { + snoozeUntil = nil + duePending = true + } + + if duePending { + if canCountActiveTimeNow() { + presentBreakBanner() + } + return + } + + guard canCountActiveTimeNow() else { return } + + activeSecondsAccumulated += clampedElapsed + if activeSecondsAccumulated >= TimeInterval(validIntervalMinutes() * 60) { + duePending = true + presentBreakBanner() + } + } + + private func presentBreakBanner(force: Bool = false) { + guard banner == nil else { return } + if !force { + guard canCountActiveTimeNow() else { return } + } + + let breakSeconds = validBreakDurationSeconds() + banner = EyeBreakBannerState( + remainingSeconds: breakSeconds, + breakDurationSeconds: breakSeconds, + snoozeMinutes: validSnoozeMinutes() + ) + + pauseMediaIfNeeded() + + if Defaults[.eyeBreakSoundEnabled] { + playReminderStartSound() + } + updateDebugState() + + countdownTask?.cancel() + countdownTask = Task { @MainActor [weak self] in + guard let self = self else { return } + + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + + guard var currentBanner = self.banner else { return } + + if !self.debugIgnoreActivityUntilDismiss && !self.canCountActiveTimeNow() { + self.resetCycle() + return + } + + currentBanner.remainingSeconds -= 1 + + if currentBanner.remainingSeconds <= 0 { + self.onCountdownCompleted() + } else { + self.banner = currentBanner + self.updateDebugState() + } + } + } + } + + private func onCountdownCompleted() { + if Defaults[.eyeBreakSoundEnabled] { + playReminderFinishSound() + } + resetCycle() + } + + private func handleNonAccruingTransition() { + if banner != nil { + resetCycle() + } + } + + private func resetCycle() { + dismissBanner() + resumeMediaIfNeeded() + activeSecondsAccumulated = 0 + duePending = false + snoozeUntil = nil + debugIgnoreActivityUntilDismiss = false + lastMonitorDate = Date() + updateDebugState() + } + + private func dismissBanner() { + countdownTask?.cancel() + countdownTask = nil + banner = nil + updateDebugState() + } + + private func clearAllState() { + dismissBanner() + resumeMediaIfNeeded() + activeSecondsAccumulated = 0 + duePending = false + snoozeUntil = nil + debugIgnoreActivityUntilDismiss = false + lastMonitorDate = Date() + updateDebugState() + } + + private func updateDebugState() { + let accruing = Defaults[.eyeBreakEnabled] && !duePending && banner == nil && canCountActiveTimeNow() + + debugState = EyeBreakDebugState( + activeSecondsAccumulated: Int(activeSecondsAccumulated), + duePending: duePending, + snoozeUntil: snoozeUntil, + isSystemSleeping: isSystemSleeping, + isDisplaySleeping: isDisplaySleeping, + isSessionActive: isSessionActive, + isAccruingActiveTime: accruing, + bannerVisible: banner != nil, + bannerRemainingSeconds: banner?.remainingSeconds + ) + } + + private func validIntervalMinutes() -> Int { + max(5, Defaults[.eyeBreakIntervalMinutes]) + } + + private func validBreakDurationSeconds() -> Int { + max(5, Defaults[.eyeBreakDurationSeconds]) + } + + private func validSnoozeMinutes() -> Int { + max(1, Defaults[.eyeBreakSnoozeMinutes]) + } + + private func canCountActiveTimeNow() -> Bool { + !isSystemSleeping && !isDisplaySleeping && isSessionActive + } + + private func pauseMediaIfNeeded() { + guard Defaults[.eyeBreakPauseMediaOnPopup] else { return } + guard MusicManager.shared.isPlaying else { return } + didPauseMediaForReminder = true + enqueueMediaCommand { + await MusicManager.shared.pauseAsync() + } + } + + private func resumeMediaIfNeeded() { + guard didPauseMediaForReminder else { return } + didPauseMediaForReminder = false + enqueueMediaCommand { + await MusicManager.shared.playAsync() + } + } + + private func enqueueMediaCommand(_ operation: @escaping @MainActor () async -> Void) { + let previous = mediaCommandTask + mediaCommandTask = Task { @MainActor in + _ = await previous?.result + guard !Task.isCancelled else { return } + await operation() + } + } + + private func playReminderStartSound() { + playSound(candidates: ["Pop", "Tink", "Glass"]) + } + + private func playReminderFinishSound() { + playSound(candidates: ["Hero", "Pop", "Glass"]) + } + + private func playSound(candidates: [String]) { + for candidate in candidates { + if let sound = NSSound(named: NSSound.Name(candidate)) { + sound.play() + return + } + } + } +} diff --git a/boringNotch/managers/MusicManager.swift b/boringNotch/managers/MusicManager.swift index 93ec9075..f4d7cdde 100644 --- a/boringNotch/managers/MusicManager.swift +++ b/boringNotch/managers/MusicManager.swift @@ -452,16 +452,26 @@ class MusicManager: ObservableObject { func play() { Task { - await activeController?.play() + await playAsync() } } func pause() { Task { - await activeController?.pause() + await pauseAsync() } } + @MainActor + func playAsync() async { + await activeController?.play() + } + + @MainActor + func pauseAsync() async { + await activeController?.pause() + } + func toggleShuffle() { Task { await activeController?.toggleShuffle() diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index f7e7ba4d..574dd616 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -34,7 +34,7 @@ enum HideNotchOption: String, Defaults.Serializable { extension Notification.Name { // MARK: - Media static let mediaControllerChanged = Notification.Name("mediaControllerChanged") - + // MARK: - Display static let selectedScreenChanged = Notification.Name("SelectedScreenChanged") static let notchHeightChanged = Notification.Name("NotchHeightChanged") @@ -189,6 +189,14 @@ extension Defaults.Keys { // MARK: Fullscreen Media Detection static let hideNotchOption = Key("hideNotchOption", default: .nowPlayingOnly) + // MARK: Wellness + static let eyeBreakEnabled = Key("eyeBreakEnabled", default: true) + static let eyeBreakIntervalMinutes = Key("eyeBreakIntervalMinutes", default: 20) + static let eyeBreakDurationSeconds = Key("eyeBreakDurationSeconds", default: 20) + static let eyeBreakSnoozeMinutes = Key("eyeBreakSnoozeMinutes", default: 5) + static let eyeBreakSoundEnabled = Key("eyeBreakSoundEnabled", default: true) + static let eyeBreakPauseMediaOnPopup = Key("eyeBreakPauseMediaOnPopup", default: false) + // MARK: Media Controller static let mediaController = Key("mediaController", default: defaultMediaController) diff --git a/crowdin.yml b/crowdin.yml index f1db027a..b23e632e 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -1,6 +1,4 @@ files: - - source: /boringNotch/*.xcstrings - translation: /boringNotch/%original_file_name% + - source: /boringNotch/Localizable.xcstrings + translation: /boringNotch/Localizable.xcstrings multilingual: 1 -bundles: - - 5