├── Foqos
├── Assets.xcassets
│ ├── Contents.json
│ ├── ThankYouStamp.imageset
│ │ ├── Thank you stamp.png
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ ├── AppIcon~ios-marketing.png
│ │ ├── AppIcon~ios-marketing 1.png
│ │ ├── AppIcon~ios-marketing 2.png
│ │ └── Contents.json
│ ├── NFCLogo.imageset
│ │ ├── image-SKsmBGsl4XyP1vak0Ribpj5gi8TGaK.png
│ │ └── Contents.json
│ ├── QRCodeLogo.imageset
│ │ ├── image-hheqtvAvZLesg9WYTVCosfDrtUtC7H.png
│ │ └── Contents.json
│ ├── ShieldIcon.imageset
│ │ ├── image-CZxT18DNf12Vjv8rrZv8V0tddRi9Xu.png
│ │ └── Contents.json
│ ├── BarcodeIcon.imageset
│ │ ├── image-BwCpmJXm80olWn4SbwsWSCKjFch3nc.png
│ │ └── Contents.json
│ ├── ScheduleIcon.imageset
│ │ ├── image-dX4gMOH0E5TFft9x1wsdjTR4hmZYeM.png
│ │ └── Contents.json
│ ├── 3DFoqosLogo.imageset
│ │ ├── ChatGPT Image Oct 17, 2025, 11_19_05 AM.png
│ │ └── Contents.json
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ ├── Twitter.imageset
│ │ ├── X (formerly Twitter)_dark.svg
│ │ ├── X (formerly Twitter)_light.svg
│ │ └── Contents.json
│ └── Threads.imageset
│ │ ├── Contents.json
│ │ ├── Threads_light.svg
│ │ └── Threads_dark.svg
├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
├── Utils
│ ├── Extensions.swift
│ ├── DocumentsUtil.swift
│ ├── RequestAuthorizer.swift
│ ├── NavigationManager.swift
│ ├── PhysicalReader.swift
│ ├── DateFormatters.swift
│ ├── RatingManager.swift
│ ├── TextFieldAlert.swift
│ ├── AppBlockerUtil.swift
│ ├── DataExporter.swift
│ ├── FamilyActivityUtil.swift
│ ├── ThemeManager.swift
│ └── TipManager.swift
├── Models
│ ├── Timers
│ │ ├── TimerActivity.swift
│ │ ├── TimerActivityUtil.swift
│ │ ├── StrategyTimerActivity.swift
│ │ ├── BreakTimerActivity.swift
│ │ └── ScheduleTimerActivity.swift
│ ├── Strategies
│ │ ├── Data
│ │ │ └── StrategyTimerData.swift
│ │ ├── BlockingStrategy.swift
│ │ ├── ManualBlockingStrategy.swift
│ │ ├── NFCManualBlockingStrategy.swift
│ │ ├── QRManualBlockingStrategy.swift
│ │ ├── NFCBlockingStrategy.swift
│ │ ├── NFCTimerBlockingStrategy.swift
│ │ ├── QRTimerBlockingStrategy.swift
│ │ └── QRCodeBlockingStrategy.swift
│ └── Schedule.swift
├── Views
│ ├── IntroView.swift
│ ├── SettingsView.swift
│ └── SupportView.swift
├── Components
│ ├── Strategy
│ │ ├── BlockingStrategyActionView.swift
│ │ ├── QRCodeView.swift
│ │ └── QRCodeScanner.swift
│ ├── Debug
│ │ ├── DebugEmptyState.swift
│ │ ├── DomainsDebugCard.swift
│ │ ├── DebugRow.swift
│ │ ├── DebugSection.swift
│ │ ├── SelectedActivityDebugCard.swift
│ │ ├── ScheduleDebugCard.swift
│ │ ├── StrategyManagerDebugCard.swift
│ │ ├── SessionDebugCard.swift
│ │ ├── DeviceActivitiesDebugCard.swift
│ │ └── ProfileDebugCard.swift
│ ├── Common
│ │ ├── EmptyView.swift
│ │ ├── AppTitle.swift
│ │ ├── AnimationModifiers.swift
│ │ ├── ChartCard.swift
│ │ ├── SectionTitle.swift
│ │ ├── CustomToggle.swift
│ │ ├── MultiStatCard.swift
│ │ ├── GlassButton.swift
│ │ └── RoundedButton.swift
│ ├── BlockedProfileView
│ │ ├── BlockingStrategyRow.swift
│ │ ├── BlockedProfileRow.swift
│ │ ├── BlockingStrategyList.swift
│ │ ├── ScheduleWarningPrompt.swift
│ │ ├── BlockedProfileScheduleSelector.swift
│ │ ├── BlockedProfileDomainSelector.swift
│ │ ├── BlockedProfileAppSelector.swift
│ │ └── BlockedProfilePhysicalUnblockSelector.swift
│ ├── BlockedProfileCards
│ │ ├── ProfileIndicators.swift
│ │ ├── ProfileStatsRow.swift
│ │ ├── StrategyInfoView.swift
│ │ ├── ProfileTimerButton.swift
│ │ └── ProfileScheduleRow.swift
│ ├── Intro
│ │ ├── AnimatedIntroContainer.swift
│ │ ├── IntroStepper.swift
│ │ ├── PermissionsIntroScreen.swift
│ │ ├── WelcomeIntroScreen.swift
│ │ └── FeaturesIntroScreen.swift
│ └── Dashboard
│ │ ├── Welcome.swift
│ │ └── VersionFooter.swift
├── Info.plist
├── Intents
│ ├── StartProfileIntent.swift
│ ├── StopProfileIntent.swift
│ ├── CheckSessionActiveIntent.swift
│ ├── CheckProfileStatusIntent.swift
│ └── BlockedProfileEntity.swift
├── foqos.entitlements
├── Tip for developer.storekit
└── foqosApp.swift
├── FoqosWidget
├── Assets.xcassets
│ ├── Contents.json
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ ├── WidgetBackground.colorset
│ │ └── Contents.json
│ └── AppIcon.appiconset
│ │ └── Contents.json
├── FoqosWidgetBundle.swift
├── Info.plist
├── FoqosWidgetExtension.entitlements
├── Models
│ └── ProfileWidgetEntry.swift
├── Widgets
│ └── ProfileControlWidget.swift
└── ProfileSelectionIntent.swift
├── .vscode
├── foqos.xcodeproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── swiftpm
│ │ └── Package.resolved
├── xcuserdata
│ └── awaseem.xcuserdatad
│ │ └── xcschemes
│ │ └── xcschememanagement.plist
└── xcshareddata
│ └── xcschemes
│ ├── FoqosDeviceMonitor.xcscheme
│ └── foqos.xcscheme
├── FoqosShieldConfig
├── FoqosShieldConfig.entitlements
└── Info.plist
├── FoqosDeviceMonitor
├── FoqosDeviceMonitor.entitlements
├── Info.plist
└── DeviceActivityMonitorExtension.swift
├── LICENSE
└── .gitignore
/Foqos/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/FoqosWidget/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Foqos/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/ThankYouStamp.imageset/Thank you stamp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awaseem/foqos/HEAD/Foqos/Assets.xcassets/ThankYouStamp.imageset/Thank you stamp.png
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awaseem/foqos/HEAD/Foqos/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing 1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awaseem/foqos/HEAD/Foqos/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing 1.png
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing 2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awaseem/foqos/HEAD/Foqos/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing 2.png
--------------------------------------------------------------------------------
/.vscode:
--------------------------------------------------------------------------------
1 | {
2 | "swift.path.swift_driver": "/usr/bin/swift",
3 | "swift.path.sourcekit_lsp": "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/sourcekit-lsp"
4 | }
5 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/NFCLogo.imageset/image-SKsmBGsl4XyP1vak0Ribpj5gi8TGaK.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awaseem/foqos/HEAD/Foqos/Assets.xcassets/NFCLogo.imageset/image-SKsmBGsl4XyP1vak0Ribpj5gi8TGaK.png
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/QRCodeLogo.imageset/image-hheqtvAvZLesg9WYTVCosfDrtUtC7H.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awaseem/foqos/HEAD/Foqos/Assets.xcassets/QRCodeLogo.imageset/image-hheqtvAvZLesg9WYTVCosfDrtUtC7H.png
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/ShieldIcon.imageset/image-CZxT18DNf12Vjv8rrZv8V0tddRi9Xu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awaseem/foqos/HEAD/Foqos/Assets.xcassets/ShieldIcon.imageset/image-CZxT18DNf12Vjv8rrZv8V0tddRi9Xu.png
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/BarcodeIcon.imageset/image-BwCpmJXm80olWn4SbwsWSCKjFch3nc.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awaseem/foqos/HEAD/Foqos/Assets.xcassets/BarcodeIcon.imageset/image-BwCpmJXm80olWn4SbwsWSCKjFch3nc.png
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/ScheduleIcon.imageset/image-dX4gMOH0E5TFft9x1wsdjTR4hmZYeM.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awaseem/foqos/HEAD/Foqos/Assets.xcassets/ScheduleIcon.imageset/image-dX4gMOH0E5TFft9x1wsdjTR4hmZYeM.png
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/3DFoqosLogo.imageset/ChatGPT Image Oct 17, 2025, 11_19_05 AM.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/awaseem/foqos/HEAD/Foqos/Assets.xcassets/3DFoqosLogo.imageset/ChatGPT Image Oct 17, 2025, 11_19_05 AM.png
--------------------------------------------------------------------------------
/foqos.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/FoqosWidget/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/FoqosWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Foqos/Utils/Extensions.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension Collection {
4 | // Returns the element at the specified index if it is within bounds, otherwise nil.
5 | subscript(safe index: Index) -> Element? {
6 | indices.contains(index) ? self[index] : nil
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/Foqos/Models/Timers/TimerActivity.swift:
--------------------------------------------------------------------------------
1 | import DeviceActivity
2 | import OSLog
3 |
4 | protocol TimerActivity {
5 | static var id: String { get }
6 |
7 | func getDeviceActivityName(from profileId: String) -> DeviceActivityName
8 |
9 | func start(for profile: SharedData.ProfileSnapshot)
10 | func stop(for profile: SharedData.ProfileSnapshot)
11 | }
12 |
--------------------------------------------------------------------------------
/Foqos/Views/IntroView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct IntroView: View {
4 | let onRequestAuthorization: () -> Void
5 |
6 | var body: some View {
7 | AnimatedIntroContainer(
8 | onRequestAuthorization: onRequestAuthorization
9 | )
10 | }
11 | }
12 |
13 | #Preview {
14 | IntroView {
15 | print("Request authorization tapped")
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/FoqosWidget/FoqosWidgetBundle.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FoqosWidgetBundle.swift
3 | // FoqosWidget
4 | //
5 | // Created by Ali Waseem on 2025-03-11.
6 | //
7 |
8 | import SwiftUI
9 | import WidgetKit
10 |
11 | @main
12 | struct FoqosWidgetBundle: WidgetBundle {
13 | var body: some Widget {
14 | ProfileControlWidget()
15 | FoqosWidgetLiveActivity()
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/FoqosWidget/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSExtension
6 |
7 | NSExtensionPointIdentifier
8 | com.apple.widgetkit-extension
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/FoqosShieldConfig/FoqosShieldConfig.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.application-groups
6 |
7 | group.dev.ambitionsoftware.foqos
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/FoqosWidget/FoqosWidgetExtension.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.application-groups
6 |
7 | group.dev.ambitionsoftware.foqos
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Foqos/Components/Strategy/BlockingStrategyActionView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct BlockingStrategyActionView: View {
4 | @Environment(\.dismiss) private var dismiss
5 |
6 | var customView: (any View)?
7 |
8 | var body: some View {
9 | VStack {
10 | if let customViewToDisplay = customView {
11 | AnyView(customViewToDisplay)
12 | }
13 | }
14 | .presentationDetents([.medium])
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/ThankYouStamp.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Thank you stamp.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/FoqosDeviceMonitor/FoqosDeviceMonitor.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.developer.family-controls
6 |
7 | com.apple.security.application-groups
8 |
9 | group.dev.ambitionsoftware.foqos
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/Twitter.imageset/X (formerly Twitter)_dark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/Twitter.imageset/X (formerly Twitter)_light.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/NFCLogo.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "image-SKsmBGsl4XyP1vak0Ribpj5gi8TGaK.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/BarcodeIcon.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "image-BwCpmJXm80olWn4SbwsWSCKjFch3nc.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/QRCodeLogo.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "image-hheqtvAvZLesg9WYTVCosfDrtUtC7H.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/ScheduleIcon.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "image-dX4gMOH0E5TFft9x1wsdjTR4hmZYeM.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/ShieldIcon.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "image-CZxT18DNf12Vjv8rrZv8V0tddRi9Xu.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/3DFoqosLogo.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "ChatGPT Image Oct 17, 2025, 11_19_05 AM.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/FoqosDeviceMonitor/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSExtension
6 |
7 | NSExtensionPointIdentifier
8 | com.apple.deviceactivity.monitor-extension
9 | NSExtensionPrincipalClass
10 | $(PRODUCT_MODULE_NAME).DeviceActivityMonitorExtension
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/foqos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "d59e09a8199b37d3d5a58522f728dec08e73dbe61a82a172d7e664c8318d995d",
3 | "pins" : [
4 | {
5 | "identity" : "codescanner",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/twostraws/CodeScanner",
8 | "state" : {
9 | "revision" : "5e886430238944c7200fc9e10dbf2d9550dba865",
10 | "version" : "2.5.2"
11 | }
12 | }
13 | ],
14 | "version" : 3
15 | }
16 |
--------------------------------------------------------------------------------
/FoqosShieldConfig/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSExtension
6 |
7 | NSExtensionPointIdentifier
8 | com.apple.ManagedSettingsUI.shield-configuration-service
9 | NSExtensionPrincipalClass
10 | $(PRODUCT_MODULE_NAME).ShieldConfigurationExtension
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/Foqos/Models/Strategies/Data/StrategyTimerData.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct StrategyTimerData: Codable {
4 | var durationInMinutes: Int
5 |
6 | static func toStrategyTimerData(from data: Data) -> StrategyTimerData {
7 | do {
8 | return try JSONDecoder().decode(StrategyTimerData.self, from: data)
9 | } catch {
10 | // If decoding fails, return a default with 15 minutes
11 | return StrategyTimerData(durationInMinutes: 15)
12 | }
13 | }
14 |
15 | static func toData(from data: StrategyTimerData) -> Data? {
16 | return try? JSONEncoder().encode(data)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Foqos/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | BGTaskSchedulerPermittedIdentifiers
6 |
7 | com.foqos.backgroundprocessing
8 |
9 | CFBundleIconName
10 | AppIcon
11 | ITSAppUsesNonExemptEncryption
12 |
13 | Supported
14 |
15 | UIBackgroundModes
16 |
17 | fetch
18 | processing
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/Foqos/Components/Debug/DebugEmptyState.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct DebugEmptyState: View {
4 | var body: some View {
5 | VStack(spacing: 16) {
6 | Image(systemName: "exclamationmark.triangle")
7 | .font(.system(size: 48))
8 | .foregroundColor(.orange)
9 |
10 | Text("No Active Profile")
11 | .font(.headline)
12 |
13 | Text("Start a profile to see debug information")
14 | .font(.subheadline)
15 | .foregroundColor(.secondary)
16 | .multilineTextAlignment(.center)
17 | }
18 | .frame(maxWidth: .infinity)
19 | .padding(.top, 100)
20 | }
21 | }
22 |
23 | #Preview {
24 | DebugEmptyState()
25 | }
26 |
--------------------------------------------------------------------------------
/Foqos/Components/Common/EmptyView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct EmptyView: View {
4 | let iconName: String
5 | let headingText: String
6 |
7 | var body: some View {
8 | VStack {
9 | Spacer()
10 |
11 | Image(systemName: iconName)
12 | .resizable()
13 | .aspectRatio(contentMode: .fit)
14 | .frame(width: 100, height: 100)
15 | .foregroundColor(.gray)
16 |
17 | Text(headingText)
18 | .font(.headline)
19 | .multilineTextAlignment(.center)
20 | .foregroundColor(.secondary)
21 | .padding()
22 |
23 | Spacer()
24 | }
25 | }
26 | }
27 |
28 | #Preview {
29 | EmptyView(iconName: "tray", headingText: "No items in your list")
30 | }
31 |
--------------------------------------------------------------------------------
/Foqos/Utils/DocumentsUtil.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import UniformTypeIdentifiers
3 |
4 | struct CSVDocument: FileDocument {
5 | static var readableContentTypes: [UTType] { [.commaSeparatedText] }
6 |
7 | var text: String
8 |
9 | init(text: String) {
10 | self.text = text
11 | }
12 |
13 | init(configuration: ReadConfiguration) throws {
14 | if let data = configuration.file.regularFileContents,
15 | let string = String(data: data, encoding: .utf8)
16 | {
17 | self.text = string
18 | } else {
19 | self.text = ""
20 | }
21 | }
22 |
23 | func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
24 | let data = text.data(using: .utf8) ?? Data()
25 | return .init(regularFileWithContents: data)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Foqos/Intents/StartProfileIntent.swift:
--------------------------------------------------------------------------------
1 | import AppIntents
2 | import SwiftData
3 |
4 | struct StartProfileIntent: AppIntent {
5 | @Dependency(key: "ModelContainer")
6 | private var modelContainer: ModelContainer
7 |
8 | @MainActor
9 | private var modelContext: ModelContext {
10 | return modelContainer.mainContext
11 | }
12 |
13 | @Parameter(title: "Profile") var profile: BlockedProfileEntity
14 |
15 | static var title: LocalizedStringResource = "Start Foqos Profile"
16 |
17 | @MainActor
18 | func perform() async throws -> some IntentResult {
19 | let strategyManager = StrategyManager.shared
20 |
21 | strategyManager
22 | .startSessionFromBackground(
23 | profile.id,
24 | context: modelContext
25 | )
26 |
27 | return .result()
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Foqos/Intents/StopProfileIntent.swift:
--------------------------------------------------------------------------------
1 | import AppIntents
2 | import SwiftData
3 |
4 | struct StopProfileIntent: AppIntent {
5 | @Dependency(key: "ModelContainer")
6 | private var modelContainer: ModelContainer
7 |
8 | @MainActor
9 | private var modelContext: ModelContext {
10 | return modelContainer.mainContext
11 | }
12 |
13 | @Parameter(title: "Profile") var profile: BlockedProfileEntity
14 |
15 | static var title: LocalizedStringResource = "Stop Foqos Profile"
16 |
17 | @MainActor
18 | func perform() async throws -> some IntentResult {
19 | let strategyManager = StrategyManager.shared
20 |
21 | strategyManager
22 | .stopSessionFromBackground(
23 | profile.id,
24 | context: modelContext
25 | )
26 |
27 | return .result()
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Foqos/foqos.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.developer.associated-domains
6 |
7 | applinks:foqos.app
8 |
9 | com.apple.developer.family-controls
10 |
11 | com.apple.developer.nfc.readersession.formats
12 |
13 | TAG
14 |
15 | com.apple.security.app-sandbox
16 |
17 | com.apple.security.application-groups
18 |
19 | group.dev.ambitionsoftware.foqos
20 |
21 | com.apple.security.files.user-selected.read-only
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/FoqosWidget/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "platform" : "ios",
6 | "size" : "1024x1024"
7 | },
8 | {
9 | "appearances" : [
10 | {
11 | "appearance" : "luminosity",
12 | "value" : "dark"
13 | }
14 | ],
15 | "idiom" : "universal",
16 | "platform" : "ios",
17 | "size" : "1024x1024"
18 | },
19 | {
20 | "appearances" : [
21 | {
22 | "appearance" : "luminosity",
23 | "value" : "tinted"
24 | }
25 | ],
26 | "idiom" : "universal",
27 | "platform" : "ios",
28 | "size" : "1024x1024"
29 | }
30 | ],
31 | "info" : {
32 | "author" : "xcode",
33 | "version" : 1
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Foqos/Components/Debug/DomainsDebugCard.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct DomainsDebugCard: View {
4 | let domains: [String]
5 |
6 | var body: some View {
7 | VStack(alignment: .leading, spacing: 8) {
8 | ForEach(domains, id: \.self) { domain in
9 | HStack {
10 | Text("•")
11 | .foregroundColor(.secondary)
12 | Text(domain)
13 | .font(.caption)
14 | .foregroundColor(.primary)
15 | }
16 | }
17 | }
18 | }
19 | }
20 |
21 | #Preview {
22 | DomainsDebugCard(domains: [
23 | "facebook.com",
24 | "twitter.com",
25 | "instagram.com",
26 | "reddit.com",
27 | ])
28 | .padding()
29 | }
30 |
31 | #Preview("Empty Domains") {
32 | DomainsDebugCard(domains: [])
33 | .padding()
34 | }
35 |
36 | #Preview("Single Domain") {
37 | DomainsDebugCard(domains: ["youtube.com"])
38 | .padding()
39 | }
40 |
--------------------------------------------------------------------------------
/Foqos/Models/Strategies/BlockingStrategy.swift:
--------------------------------------------------------------------------------
1 | import SwiftData
2 | import SwiftUI
3 |
4 | enum SessionStatus {
5 | case started(BlockedProfileSession)
6 | case ended(BlockedProfiles)
7 | }
8 |
9 | protocol BlockingStrategy {
10 | static var id: String { get }
11 | var name: String { get }
12 | var description: String { get }
13 | var iconType: String { get }
14 | var color: Color { get }
15 |
16 | // Callback closures session creation
17 | var onSessionCreation: ((SessionStatus) -> Void)? {
18 | get set
19 | }
20 |
21 | var onErrorMessage: ((String) -> Void)? {
22 | get set
23 | }
24 |
25 | func getIdentifier() -> String
26 | func startBlocking(
27 | context: ModelContext,
28 | profile: BlockedProfiles,
29 | forceStart: Bool?
30 | ) -> (any View)?
31 | func stopBlocking(context: ModelContext, session: BlockedProfileSession)
32 | -> (any View)?
33 | }
34 |
--------------------------------------------------------------------------------
/Foqos/Utils/RequestAuthorizer.swift:
--------------------------------------------------------------------------------
1 | import DeviceActivity
2 | import FamilyControls
3 | import ManagedSettings
4 | import SwiftUI
5 |
6 | class RequestAuthorizer: ObservableObject {
7 | @Published var isAuthorized = false
8 |
9 | func requestAuthorization() {
10 | Task {
11 | do {
12 | try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
13 | print("Individual authorization successful")
14 |
15 | // Dispatch the update to the main thread
16 | await MainActor.run {
17 | self.isAuthorized = true
18 | }
19 | } catch {
20 | print("Error requesting authorization: \(error)")
21 | await MainActor.run {
22 | self.isAuthorized = false
23 | }
24 | }
25 | }
26 | }
27 |
28 | func getAuthorizationStatus() -> AuthorizationStatus {
29 | return AuthorizationCenter.shared.authorizationStatus
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Foqos/Components/Common/AppTitle.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct AppTitle: View {
4 | let title: String
5 | let font: Font
6 | let fontWeight: Font.Weight
7 | let horizontalPadding: CGFloat
8 |
9 | init(
10 | _ title: String = "Foqos",
11 | font: Font = .largeTitle,
12 | fontWeight: Font.Weight = .bold,
13 | horizontalPadding: CGFloat = 16
14 | ) {
15 | self.title = title
16 | self.font = font
17 | self.fontWeight = fontWeight
18 | self.horizontalPadding = horizontalPadding
19 | }
20 |
21 | var body: some View {
22 | Text(title)
23 | .font(font)
24 | .fontWeight(fontWeight)
25 | .padding(.horizontal, horizontalPadding)
26 | }
27 | }
28 |
29 | // Preview
30 | #Preview {
31 | VStack(spacing: 24) {
32 | AppTitle()
33 |
34 | AppTitle("Foqos", font: .title, fontWeight: .semibold)
35 |
36 | AppTitle("Custom Title", font: .title2, fontWeight: .medium, horizontalPadding: 24)
37 | }
38 | .padding(20)
39 | }
40 |
--------------------------------------------------------------------------------
/Foqos/Utils/NavigationManager.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | class NavigationManager: ObservableObject {
4 | @Published var profileId: String? = nil
5 | @Published var link: URL? = nil
6 |
7 | @Published var navigateToProfileId: String? = nil
8 |
9 | func handleLink(_ url: URL) {
10 | let components = URLComponents(url: url, resolvingAgainstBaseURL: true)
11 | guard let path = components?.path else { return }
12 |
13 | let parts = path.split(separator: "/")
14 | if let basePath = parts[safe: 0], let profileId = parts[safe: 1] {
15 | switch String(basePath) {
16 | case "profile":
17 | self.profileId = String(profileId)
18 | self.link = url
19 | case "navigate":
20 | self.navigateToProfileId = String(profileId)
21 | self.link = url
22 | default:
23 | break
24 | }
25 | }
26 | }
27 |
28 | func clearNavigation() {
29 | profileId = nil
30 | link = nil
31 | navigateToProfileId = nil
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Foqos/Utils/PhysicalReader.swift:
--------------------------------------------------------------------------------
1 | import CodeScanner
2 | import CoreNFC
3 | import SwiftUI
4 |
5 | class PhysicalReader {
6 | private let nfcScanner: NFCScannerUtil = NFCScannerUtil()
7 |
8 | func readNFCTag(
9 | onSuccess: @escaping (String) -> Void,
10 | ) {
11 | nfcScanner.onTagScanned = { result in
12 | let tagId = result.url ?? result.id
13 | onSuccess(tagId)
14 | }
15 |
16 | nfcScanner.scan(profileName: "")
17 | }
18 |
19 | func readQRCode(
20 | onSuccess: @escaping (String) -> Void,
21 | onFailure: @escaping (String) -> Void
22 | ) -> some View {
23 | return LabeledCodeScannerView(
24 | heading: "Scan to set",
25 | subtitle: "Point your camera at a QR/Barcode code to set a physical unblock."
26 | ) { result in
27 | switch result {
28 | case .success(let scanResult):
29 | onSuccess(scanResult.string)
30 | case .failure(let error):
31 | onFailure(error.localizedDescription)
32 | }
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Foqos/Components/Debug/DebugRow.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct DebugRow: View {
4 | let label: String
5 | let value: String
6 |
7 | var body: some View {
8 | HStack(alignment: .top) {
9 | Text(label + ":")
10 | .font(.caption)
11 | .foregroundColor(.secondary)
12 | .frame(width: 160, alignment: .leading)
13 |
14 | Text(value)
15 | .font(.caption.monospaced())
16 | .foregroundColor(.primary)
17 | .textSelection(.enabled)
18 |
19 | Spacer()
20 | }
21 | }
22 | }
23 |
24 | #Preview {
25 | DebugRow(label: "Session ID", value: "ABC123-DEF456")
26 | .padding()
27 | }
28 |
29 | #Preview("Long Value") {
30 | DebugRow(
31 | label: "Profile ID",
32 | value: "550e8400-e29b-41d4-a716-446655440000"
33 | )
34 | .padding()
35 | }
36 |
37 | #Preview("Boolean Value") {
38 | VStack(spacing: 8) {
39 | DebugRow(label: "Is Active", value: "true")
40 | DebugRow(label: "Is Break Available", value: "false")
41 | }
42 | .padding()
43 | }
44 |
--------------------------------------------------------------------------------
/FoqosWidget/Models/ProfileWidgetEntry.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileWidgetEntry.swift
3 | // FoqosWidget
4 | //
5 | // Created by Ali Waseem on 2025-03-11.
6 | //
7 |
8 | import Foundation
9 | import WidgetKit
10 |
11 | // MARK: - Widget Entry Model
12 | struct ProfileWidgetEntry: TimelineEntry {
13 | let date: Date
14 | let selectedProfileId: String?
15 | let profileName: String?
16 | let activeSession: SharedData.SessionSnapshot?
17 | let profileSnapshot: SharedData.ProfileSnapshot?
18 | let deepLinkURL: URL?
19 | let focusMessage: String
20 | let useProfileURL: Bool?
21 |
22 | var isSessionActive: Bool {
23 | if let active = activeSession {
24 | return active.endTime == nil
25 | } else {
26 | return false
27 | }
28 | }
29 |
30 | var isBreakActive: Bool {
31 | guard let session = activeSession else { return false }
32 | return session.breakStartTime != nil && session.breakEndTime == nil
33 | }
34 |
35 | var sessionStartTime: Date? {
36 | return activeSession?.startTime
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/FoqosWidget/Widgets/ProfileControlWidget.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileControlWidget.swift
3 | // FoqosWidget
4 | //
5 | // Created by Ali Waseem on 2025-03-11.
6 | //
7 |
8 | import AppIntents
9 | import SwiftUI
10 | import WidgetKit
11 |
12 | // MARK: - Widget Configuration
13 | struct ProfileControlWidget: Widget {
14 | let kind: String = "ProfileControlWidget"
15 |
16 | var body: some WidgetConfiguration {
17 | AppIntentConfiguration(
18 | kind: kind, intent: ProfileSelectionIntent.self, provider: ProfileControlProvider()
19 | ) { entry in
20 | ProfileWidgetEntryView(entry: entry)
21 | .containerBackground(for: .widget) {
22 | // Use the entry's background color or clear if inactive
23 | if entry.isSessionActive {
24 | if entry.isBreakActive {
25 | Color.orange
26 | } else {
27 | Color.green
28 | }
29 | }
30 | }
31 | }
32 | .configurationDisplayName("Foqos Profile")
33 | .description("Monitor and control your selected focus profile")
34 | .supportedFamilies([.systemSmall])
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Ali Waseem
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/Threads.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Threads_light.svg",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "appearances" : [
10 | {
11 | "appearance" : "luminosity",
12 | "value" : "dark"
13 | }
14 | ],
15 | "filename" : "Threads_dark.svg",
16 | "idiom" : "universal",
17 | "scale" : "1x"
18 | },
19 | {
20 | "idiom" : "universal",
21 | "scale" : "2x"
22 | },
23 | {
24 | "appearances" : [
25 | {
26 | "appearance" : "luminosity",
27 | "value" : "dark"
28 | }
29 | ],
30 | "idiom" : "universal",
31 | "scale" : "2x"
32 | },
33 | {
34 | "idiom" : "universal",
35 | "scale" : "3x"
36 | },
37 | {
38 | "appearances" : [
39 | {
40 | "appearance" : "luminosity",
41 | "value" : "dark"
42 | }
43 | ],
44 | "idiom" : "universal",
45 | "scale" : "3x"
46 | }
47 | ],
48 | "info" : {
49 | "author" : "xcode",
50 | "version" : 1
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/Twitter.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "X (formerly Twitter)_light.svg",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "appearances" : [
10 | {
11 | "appearance" : "luminosity",
12 | "value" : "dark"
13 | }
14 | ],
15 | "filename" : "X (formerly Twitter)_dark.svg",
16 | "idiom" : "universal",
17 | "scale" : "1x"
18 | },
19 | {
20 | "idiom" : "universal",
21 | "scale" : "2x"
22 | },
23 | {
24 | "appearances" : [
25 | {
26 | "appearance" : "luminosity",
27 | "value" : "dark"
28 | }
29 | ],
30 | "idiom" : "universal",
31 | "scale" : "2x"
32 | },
33 | {
34 | "idiom" : "universal",
35 | "scale" : "3x"
36 | },
37 | {
38 | "appearances" : [
39 | {
40 | "appearance" : "luminosity",
41 | "value" : "dark"
42 | }
43 | ],
44 | "idiom" : "universal",
45 | "scale" : "3x"
46 | }
47 | ],
48 | "info" : {
49 | "author" : "xcode",
50 | "version" : 1
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Foqos/Utils/DateFormatters.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | enum DateFormatters {
4 | static func formatDate(_ date: Date) -> String {
5 | let formatter = DateFormatter()
6 | formatter.dateStyle = .short
7 | formatter.timeStyle = .medium
8 | return formatter.string(from: date)
9 | }
10 |
11 | static func formatDuration(_ duration: TimeInterval) -> String {
12 | let hours = Int(duration) / 3600
13 | let minutes = (Int(duration) % 3600) / 60
14 | let seconds = Int(duration) % 60
15 |
16 | if hours > 0 {
17 | return String(format: "%dh %dm %ds", hours, minutes, seconds)
18 | } else if minutes > 0 {
19 | return String(format: "%dm %ds", minutes, seconds)
20 | } else {
21 | return String(format: "%ds", seconds)
22 | }
23 | }
24 |
25 | static func formatMinutes(_ durationInMinutes: Int) -> String {
26 | if durationInMinutes <= 60 {
27 | return "\(durationInMinutes) min"
28 | } else {
29 | let hours = durationInMinutes / 60
30 | let minutes = durationInMinutes % 60
31 | if minutes == 0 {
32 | return "\(hours)h"
33 | } else {
34 | return "\(hours)h \(minutes)m"
35 | }
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Foqos/Components/Common/AnimationModifiers.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct FadeInSlideAnimation: ViewModifier {
4 | let delay: Double
5 | let slideOffset: CGFloat
6 | let duration: Double
7 |
8 | @State private var opacity: Double = 0.0
9 | @State private var offset: CGFloat
10 |
11 | init(delay: Double, slideOffset: CGFloat = 30, duration: Double = 0.4) {
12 | self.delay = delay
13 | self.slideOffset = slideOffset
14 | self.duration = duration
15 | self._offset = State(initialValue: slideOffset)
16 | }
17 |
18 | func body(content: Content) -> some View {
19 | content
20 | .opacity(opacity)
21 | .offset(y: offset)
22 | .onAppear {
23 | withAnimation(
24 | .spring(
25 | response: duration,
26 | dampingFraction: 0.6,
27 | blendDuration: 0
28 | ).delay(delay)
29 | ) {
30 | opacity = 1.0
31 | offset = 0
32 | }
33 | }
34 | }
35 | }
36 |
37 | extension View {
38 | func fadeInSlide(delay: Double, slideOffset: CGFloat = 30, duration: Double = 0.4) -> some View {
39 | modifier(FadeInSlideAnimation(delay: delay, slideOffset: slideOffset, duration: duration))
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/FoqosDeviceMonitor/DeviceActivityMonitorExtension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeviceActivityMonitorExtension.swift
3 | // FoqosDeviceMonitor
4 | //
5 | // Created by Ali Waseem on 2025-05-27.
6 | //
7 |
8 | import DeviceActivity
9 | import ManagedSettings
10 | import OSLog
11 |
12 | private let log = Logger(
13 | subsystem: "com.foqos.monitor",
14 | category: "DeviceActivity"
15 | )
16 |
17 | // Optionally override any of the functions below.
18 | // Make sure that your class name matches the NSExtensionPrincipalClass in your Info.plist.
19 | class DeviceActivityMonitorExtension: DeviceActivityMonitor {
20 | private let appBlocker = AppBlockerUtil()
21 |
22 | override init() {
23 | super.init()
24 | }
25 |
26 | override func intervalDidStart(for activity: DeviceActivityName) {
27 | super.intervalDidStart(for: activity)
28 |
29 | log.info("intervalDidStart for activity: \(activity.rawValue)")
30 | TimerActivityUtil.startTimerActivity(for: activity)
31 | }
32 |
33 | override func intervalDidEnd(for activity: DeviceActivityName) {
34 | super.intervalDidEnd(for: activity)
35 |
36 | log.info("intervalDidEnd for activity: \(activity.rawValue)")
37 | TimerActivityUtil.stopTimerActivity(for: activity)
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Foqos/Utils/RatingManager.swift:
--------------------------------------------------------------------------------
1 | import StoreKit
2 | import SwiftUI
3 |
4 | class RatingManager: ObservableObject {
5 | @AppStorage("launchCount") private var launchCount = 0
6 | @AppStorage("lastVersionPromptedForReview") private var lastVersionPromptedForReview: String?
7 | @Published var shouldRequestReview = false
8 |
9 | func incrementLaunchCount() {
10 | launchCount += 1
11 | checkIfShouldRequestReview()
12 | }
13 |
14 | private func checkIfShouldRequestReview() {
15 | let currentVersion =
16 | Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
17 |
18 | // Only prompt if we haven't for this version and have enough launches
19 | guard lastVersionPromptedForReview != currentVersion,
20 | launchCount >= 3
21 | else { return }
22 |
23 | shouldRequestReview = true
24 | lastVersionPromptedForReview = currentVersion
25 | requestReview()
26 | }
27 |
28 | private func requestReview() {
29 | guard
30 | let scene = UIApplication.shared.connectedScenes.first(
31 | where: { $0.activationState == .foregroundActive }
32 | ) as? UIWindowScene
33 | else {
34 | return
35 | }
36 |
37 | SKStoreReviewController.requestReview(in: scene)
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Foqos/Components/Common/ChartCard.swift:
--------------------------------------------------------------------------------
1 | import Charts
2 | import SwiftUI
3 |
4 | struct ChartCard: View {
5 | let title: String
6 | let subtitle: String?
7 | let content: () -> Content
8 |
9 | init(
10 | title: String,
11 | subtitle: String? = nil,
12 | @ViewBuilder content: @escaping () -> Content
13 | ) {
14 | self.title = title
15 | self.subtitle = subtitle
16 | self.content = content
17 | }
18 |
19 | var body: some View {
20 | ZStack {
21 | RoundedRectangle(cornerRadius: 24, style: .continuous)
22 | .fill(.ultraThinMaterial)
23 |
24 | VStack(alignment: .leading, spacing: 12) {
25 | VStack(alignment: .leading, spacing: 4) {
26 | Text(title)
27 | .font(.subheadline)
28 | .fontWeight(.semibold)
29 | .foregroundStyle(.primary)
30 |
31 | if let subtitle = subtitle, !subtitle.isEmpty {
32 | Text(subtitle)
33 | .font(.caption)
34 | .foregroundStyle(.secondary)
35 | }
36 | }
37 |
38 | content()
39 | .frame(maxWidth: .infinity, minHeight: 160, alignment: .center)
40 | }
41 | .padding(16)
42 | }
43 | .frame(maxWidth: .infinity)
44 | }
45 | }
46 |
47 | // Preview intentionally omitted to keep build clean
48 |
--------------------------------------------------------------------------------
/Foqos/Components/BlockedProfileView/BlockingStrategyRow.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct StrategyRow: View {
4 | @EnvironmentObject var themeManager: ThemeManager
5 |
6 | let strategy: BlockingStrategy
7 | let isSelected: Bool
8 | let onTap: () -> Void
9 |
10 | var body: some View {
11 | Button(action: onTap) {
12 | HStack(spacing: 16) {
13 | Image(systemName: strategy.iconType)
14 | .font(.title2)
15 | .foregroundColor(.gray)
16 | .frame(width: 24, height: 24)
17 |
18 | VStack(alignment: .leading, spacing: 4) {
19 | Text(strategy.name)
20 | .font(.headline)
21 |
22 | Text(strategy.description)
23 | .font(.subheadline)
24 | .foregroundColor(.secondary)
25 | .lineLimit(3)
26 | }
27 | .padding(.vertical, 8)
28 |
29 | Spacer()
30 |
31 | Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
32 | .foregroundColor(isSelected ? themeManager.themeColor : .secondary)
33 | .font(.system(size: 20))
34 | }
35 | }
36 | .buttonStyle(PlainButtonStyle())
37 | }
38 | }
39 |
40 | #Preview {
41 | StrategyRow(strategy: NFCBlockingStrategy(), isSelected: true, onTap: {})
42 | }
43 |
44 | #Preview {
45 | StrategyRow(strategy: NFCBlockingStrategy(), isSelected: true, onTap: {})
46 | }
47 |
--------------------------------------------------------------------------------
/Foqos/Intents/CheckSessionActiveIntent.swift:
--------------------------------------------------------------------------------
1 | import AppIntents
2 | import SwiftData
3 |
4 | struct CheckSessionActiveIntent: AppIntent {
5 | @Dependency(key: "ModelContainer")
6 | private var modelContainer: ModelContainer
7 |
8 | @MainActor
9 | private var modelContext: ModelContext {
10 | return modelContainer.mainContext
11 | }
12 |
13 | static var title: LocalizedStringResource = "Check if Foqos Session is Active"
14 | static var description = IntentDescription(
15 | "Check if any Foqos blocking session is currently active and return true or false. Useful for automation and shortcuts."
16 | )
17 |
18 | static var openAppWhenRun: Bool = false
19 |
20 | @MainActor
21 | func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog {
22 | let strategyManager = StrategyManager.shared
23 |
24 | // Load the active session (this syncs scheduled sessions)
25 | strategyManager.loadActiveSession(context: modelContext)
26 |
27 | // Check if there's any active session using the isBlocking property
28 | let isActive = strategyManager.isBlocking
29 |
30 | let dialogMessage =
31 | isActive
32 | ? "A Foqos session is currently active."
33 | : "No Foqos session is active."
34 |
35 | return .result(
36 | value: isActive,
37 | dialog: .init(stringLiteral: dialogMessage)
38 | )
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Foqos/Components/Debug/DebugSection.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct DebugSection: View {
4 | let title: String
5 | let content: Content
6 |
7 | init(title: String, @ViewBuilder content: () -> Content) {
8 | self.title = title
9 | self.content = content()
10 | }
11 |
12 | var body: some View {
13 | VStack(alignment: .leading, spacing: 12) {
14 | Text(title)
15 | .font(.headline.monospaced())
16 | .foregroundColor(.primary)
17 |
18 | VStack(alignment: .leading, spacing: 8) {
19 | content
20 | }
21 | .padding(.vertical, 12)
22 | }
23 | }
24 | }
25 |
26 | #Preview {
27 | DebugSection(title: "Session Information") {
28 | DebugRow(label: "Session ID", value: "ABC123-DEF456")
29 | DebugRow(label: "Is Active", value: "true")
30 | DebugRow(label: "Duration", value: "1h 23m 45s")
31 | }
32 | .padding()
33 | }
34 |
35 | #Preview("Multiple Sections") {
36 | ScrollView {
37 | VStack(spacing: 16) {
38 | DebugSection(title: "Profile Details") {
39 | DebugRow(label: "Name", value: "Work Focus")
40 | DebugRow(label: "Strategy", value: "NFC")
41 | }
42 |
43 | DebugSection(title: "Session Status") {
44 | DebugRow(label: "Is Active", value: "true")
45 | DebugRow(label: "Elapsed Time", value: "45m 12s")
46 | }
47 | }
48 | .padding()
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Foqos/Intents/CheckProfileStatusIntent.swift:
--------------------------------------------------------------------------------
1 | import AppIntents
2 | import SwiftData
3 |
4 | struct CheckProfileStatusIntent: AppIntent {
5 | @Dependency(key: "ModelContainer")
6 | private var modelContainer: ModelContainer
7 |
8 | @MainActor
9 | private var modelContext: ModelContext {
10 | return modelContainer.mainContext
11 | }
12 |
13 | @Parameter(title: "Profile") var profile: BlockedProfileEntity
14 |
15 | static var title: LocalizedStringResource = "Foqos Profile Status"
16 | static var description = IntentDescription(
17 | "Check if a Foqos profile is currently active and return the status as a boolean value.")
18 |
19 | @MainActor
20 | func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog {
21 | let strategyManager = StrategyManager.shared
22 |
23 | // Load the active session (this syncs scheduled sessions)
24 | strategyManager.loadActiveSession(context: modelContext)
25 |
26 | // Check if there's an active session and if it belongs to the specified profile
27 | let isActive = strategyManager.activeSession?.blockedProfile.id == profile.id
28 |
29 | let dialogMessage =
30 | isActive
31 | ? "\(profile.name) is currently active."
32 | : "\(profile.name) is not active."
33 |
34 | return .result(
35 | value: isActive,
36 | dialog: .init(stringLiteral: dialogMessage)
37 | )
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Foqos/Components/BlockedProfileCards/ProfileIndicators.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct ProfileIndicators: View {
4 | let enableLiveActivity: Bool
5 | let hasReminders: Bool
6 | let enableBreaks: Bool
7 | let enableStrictMode: Bool
8 |
9 | var body: some View {
10 | HStack(spacing: 16) {
11 | if enableBreaks {
12 | indicatorView(label: "Breaks")
13 | }
14 | if enableStrictMode {
15 | indicatorView(label: "Strict")
16 | }
17 | if enableLiveActivity {
18 | indicatorView(label: "Live Activity")
19 | }
20 | if hasReminders {
21 | indicatorView(label: "Reminders")
22 | }
23 | }
24 | }
25 |
26 | private func indicatorView(label: String) -> some View {
27 | HStack(spacing: 6) {
28 | Circle()
29 | .fill(Color.primary.opacity(0.85))
30 | .frame(width: 6, height: 6)
31 |
32 | Text(label)
33 | .font(.caption2)
34 | .foregroundColor(.secondary)
35 | }
36 | }
37 | }
38 |
39 | #Preview {
40 | VStack(spacing: 20) {
41 | ProfileIndicators(
42 | enableLiveActivity: true,
43 | hasReminders: true,
44 | enableBreaks: false,
45 | enableStrictMode: false,
46 | )
47 | ProfileIndicators(
48 | enableLiveActivity: false,
49 | hasReminders: false,
50 | enableBreaks: true,
51 | enableStrictMode: true,
52 | )
53 | }
54 | .padding()
55 | .background(Color(.systemGroupedBackground))
56 | }
57 |
--------------------------------------------------------------------------------
/Foqos/Components/BlockedProfileView/BlockedProfileRow.swift:
--------------------------------------------------------------------------------
1 | import FamilyControls
2 | import SwiftUI
3 |
4 | struct ProfileRow: View {
5 | let profile: BlockedProfiles
6 |
7 | var formattedUpdateTime: String {
8 | let formatter = RelativeDateTimeFormatter()
9 | formatter.unitsStyle = .short
10 | return formatter.localizedString(for: profile.updatedAt, relativeTo: Date())
11 | }
12 |
13 | var selectedItemsCount: Int {
14 | FamilyActivityUtil.countSelectedActivities(profile.selectedActivity)
15 | }
16 |
17 | var body: some View {
18 | HStack {
19 | VStack(alignment: .leading, spacing: 12) {
20 | Text(profile.name)
21 | .font(.headline)
22 |
23 | HStack(spacing: 4) {
24 | Image(systemName: "clock")
25 | Text("Updated \(formattedUpdateTime)")
26 | }
27 | .foregroundStyle(.secondary)
28 | .font(.caption)
29 | }
30 |
31 | Spacer()
32 |
33 | HStack(spacing: 4) {
34 | Image(systemName: "list.bullet.circle.fill")
35 | Text("\(selectedItemsCount) items")
36 | }
37 | .foregroundStyle(.secondary)
38 | .font(.subheadline)
39 | }
40 | }
41 | }
42 |
43 | #Preview {
44 | let previewProfile = BlockedProfiles(
45 | name: "⌛ School Hours",
46 | selectedActivity: FamilyActivitySelection(),
47 | createdAt: Date(),
48 | updatedAt: Date().addingTimeInterval(-3600)
49 | )
50 |
51 | return ProfileRow(profile: previewProfile)
52 | .padding()
53 | }
54 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/Threads.imageset/Threads_light.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/Threads.imageset/Threads_dark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Foqos/Utils/TextFieldAlert.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import UIKit
3 |
4 | struct TextFieldAlert: UIViewControllerRepresentable {
5 | @Binding var isPresented: Bool
6 | var title: String
7 | var message: String?
8 | @Binding var text: String
9 | var placeholder: String
10 | var confirmTitle: String = "Create"
11 | var cancelTitle: String = "Cancel"
12 | var onConfirm: (String) -> Void
13 |
14 | func makeUIViewController(context: Context) -> UIViewController {
15 | UIViewController()
16 | }
17 |
18 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
19 | guard isPresented, uiViewController.presentedViewController == nil else { return }
20 |
21 | let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
22 | alert.addTextField { textField in
23 | textField.placeholder = placeholder
24 | textField.text = text
25 | textField.clearButtonMode = .whileEditing
26 | textField.returnKeyType = .done
27 | }
28 |
29 | let cancelAction = UIAlertAction(title: cancelTitle, style: .cancel) { _ in
30 | isPresented = false
31 | }
32 | alert.addAction(cancelAction)
33 |
34 | let confirmAction = UIAlertAction(title: confirmTitle, style: .default) { _ in
35 | let value = alert.textFields?.first?.text ?? ""
36 | text = value
37 | onConfirm(value)
38 | isPresented = false
39 | }
40 | alert.addAction(confirmAction)
41 |
42 | uiViewController.present(alert, animated: true)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Foqos/Components/BlockedProfileView/BlockingStrategyList.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct BlockingStrategyList: View {
4 | let strategies: [BlockingStrategy]
5 | @Binding var selectedStrategy: BlockingStrategy?
6 | var disabled: Bool = false
7 | var disabledText: String?
8 |
9 | var body: some View {
10 | Section("Blocking Strategy") {
11 | VStack(alignment: .leading, spacing: 8) {
12 | VStack {
13 | ForEach(strategies, id: \.name) { strategy in
14 | StrategyRow(
15 | strategy: strategy,
16 | isSelected: selectedStrategy?.name == strategy.name,
17 | onTap: {
18 | if !disabled {
19 | selectedStrategy = strategy
20 | }
21 | }
22 | )
23 | .opacity(disabled ? 0.5 : 1)
24 | }
25 | }
26 |
27 | if let disabledText = disabledText, disabled {
28 | Text(disabledText)
29 | .foregroundStyle(.red)
30 | .padding(.top, 4)
31 | .font(.caption)
32 | }
33 | }.padding(0)
34 | }
35 | }
36 | }
37 |
38 | #Preview {
39 | @Previewable @State var selectedStrategy: BlockingStrategy?
40 | NavigationStack {
41 | Form {
42 | Section {
43 | BlockingStrategyList(
44 | strategies: [NFCBlockingStrategy(), ManualBlockingStrategy()],
45 | selectedStrategy: $selectedStrategy,
46 | disabled: true,
47 | disabledText: "Strategy selection is locked"
48 | )
49 | }
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Foqos/Intents/BlockedProfileEntity.swift:
--------------------------------------------------------------------------------
1 | import AppIntents
2 | import SwiftData
3 |
4 | struct BlockedProfileEntity: AppEntity, Identifiable {
5 | let profile: BlockedProfiles
6 |
7 | var id: UUID { profile.id }
8 | var name: String { profile.name }
9 |
10 | static var typeDisplayRepresentation = TypeDisplayRepresentation(
11 | name: "Profile"
12 | )
13 |
14 | static var defaultQuery = BlockedProfilesQuery()
15 |
16 | var displayRepresentation: DisplayRepresentation {
17 | DisplayRepresentation(title: "\(profile.name)")
18 | }
19 | }
20 |
21 | struct BlockedProfilesQuery: EntityQuery {
22 | @Dependency(key: "ModelContainer")
23 | private var modelContainer: ModelContainer
24 |
25 | @MainActor
26 | private var modelContext: ModelContext {
27 | return modelContainer.mainContext
28 | }
29 |
30 | @MainActor
31 | func entities(for identifiers: [UUID]) async throws
32 | -> [BlockedProfileEntity]
33 | {
34 | let results = try modelContext.fetch(
35 | FetchDescriptor(
36 | predicate: #Predicate { identifiers.contains($0.id) }
37 | )
38 | )
39 | return results.map { BlockedProfileEntity(profile: $0) }
40 | }
41 |
42 | @MainActor
43 | func suggestedEntities() async throws -> [BlockedProfileEntity] {
44 | let results = try modelContext.fetch(
45 | FetchDescriptor(sortBy: [.init(\.name)])
46 | )
47 | return results.map { BlockedProfileEntity(profile: $0) }
48 | }
49 |
50 | func defaultResult() async -> BlockedProfileEntity? {
51 | try? await suggestedEntities().first
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/Foqos/Models/Strategies/ManualBlockingStrategy.swift:
--------------------------------------------------------------------------------
1 | import SwiftData
2 | import SwiftUI
3 |
4 | class ManualBlockingStrategy: BlockingStrategy {
5 | static var id: String = "ManualBlockingStrategy"
6 |
7 | var name: String = "Manual"
8 | var description: String =
9 | "Block and unblock profiles manually through the app"
10 | var iconType: String = "button.horizontal.top.press.fill"
11 | var color: Color = .blue
12 |
13 | var onSessionCreation: ((SessionStatus) -> Void)?
14 | var onErrorMessage: ((String) -> Void)?
15 |
16 | private let appBlocker: AppBlockerUtil = AppBlockerUtil()
17 |
18 | func getIdentifier() -> String {
19 | return ManualBlockingStrategy.id
20 | }
21 |
22 | func startBlocking(
23 | context: ModelContext,
24 | profile: BlockedProfiles,
25 | forceStart: Bool?
26 | ) -> (any View)? {
27 | self.appBlocker
28 | .activateRestrictions(for: BlockedProfiles.getSnapshot(for: profile))
29 |
30 | let activeSession =
31 | BlockedProfileSession
32 | .createSession(
33 | in: context,
34 | withTag: ManualBlockingStrategy.id,
35 | withProfile: profile,
36 | forceStart: forceStart ?? false
37 | )
38 |
39 | self.onSessionCreation?(.started(activeSession))
40 |
41 | return nil
42 | }
43 |
44 | func stopBlocking(
45 | context: ModelContext,
46 | session: BlockedProfileSession
47 | ) -> (any View)? {
48 | session.endSession()
49 | self.appBlocker.deactivateRestrictions()
50 |
51 | self.onSessionCreation?(.ended(session.blockedProfile))
52 |
53 | return nil
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Foqos/Components/Common/SectionTitle.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct SectionTitle: View {
4 | let title: String
5 | let buttonText: String?
6 | let buttonAction: (() -> Void)?
7 | let buttonIcon: String?
8 |
9 | init(
10 | _ title: String, buttonText: String? = nil, buttonAction: (() -> Void)? = nil,
11 | buttonIcon: String? = nil
12 | ) {
13 | self.title = title
14 | self.buttonText = buttonText
15 | self.buttonAction = buttonAction
16 | self.buttonIcon = buttonIcon
17 | }
18 |
19 | var body: some View {
20 | HStack {
21 | Text(title)
22 | .font(.headline)
23 | .fontWeight(.medium)
24 | .foregroundColor(.secondary)
25 |
26 | Spacer()
27 |
28 | if let buttonText = buttonText, let buttonAction = buttonAction {
29 | RoundedButton(buttonText, action: buttonAction, iconName: buttonIcon)
30 | }
31 | }
32 | .padding(.bottom, 10)
33 | }
34 | }
35 |
36 | // Preview
37 | #Preview {
38 | VStack(spacing: 24) {
39 | SectionTitle("Recent Activity")
40 |
41 | SectionTitle(
42 | "Your Focus Sessions",
43 | buttonText: "See All",
44 | buttonAction: { print("See All tapped") })
45 |
46 | SectionTitle(
47 | "Weekly Insights",
48 | buttonText: "View Report",
49 | buttonAction: { print("View Report tapped") })
50 |
51 | SectionTitle(
52 | "Achievements",
53 | buttonText: "Manage",
54 | buttonAction: { print("Manage tapped") })
55 |
56 | SectionTitle(
57 | "App Usage",
58 | buttonText: "Settings",
59 | buttonAction: { print("Settings tapped") })
60 | }
61 | .padding(20)
62 | }
63 |
--------------------------------------------------------------------------------
/Foqos/Components/Common/CustomToggle.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct CustomToggle: View {
4 | @EnvironmentObject var themeManager: ThemeManager
5 |
6 | let title: String
7 | let description: String
8 | @Binding var isOn: Bool
9 | var isDisabled: Bool = false
10 | var errorMessage: String? = nil
11 |
12 | var body: some View {
13 | VStack(alignment: .leading, spacing: 0) {
14 | Toggle(title, isOn: $isOn)
15 | .disabled(isDisabled)
16 | .tint(themeManager.themeColor)
17 |
18 | Text(description)
19 | .font(.caption)
20 | .foregroundColor(.secondary)
21 | .padding(.vertical, 4)
22 | .fixedSize(horizontal: false, vertical: true)
23 | .lineLimit(nil)
24 | .padding(.trailing, 80)
25 |
26 | if isDisabled && errorMessage != nil {
27 | Text(errorMessage!)
28 | .font(.caption)
29 | .foregroundColor(.red)
30 | }
31 | }
32 | }
33 | }
34 |
35 | #Preview {
36 | VStack(alignment: .leading, spacing: 16) {
37 | CustomToggle(
38 | title: "Enable Feature",
39 | description: "This is a description of what this toggle does.",
40 | isOn: .constant(true)
41 | )
42 |
43 | CustomToggle(
44 | title: "Enable Feature",
45 | description:
46 | "This is a toggle with a really long description so that it doesn't look so weird and super strange",
47 | isOn: .constant(false)
48 | )
49 |
50 | CustomToggle(
51 | title: "Disabled Toggle",
52 | description: "This toggle is currently disabled.",
53 | isOn: .constant(false),
54 | isDisabled: true
55 | )
56 | }
57 | .padding()
58 | }
59 |
--------------------------------------------------------------------------------
/Foqos/Components/BlockedProfileView/ScheduleWarningPrompt.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct ScheduleWarningPrompt: View {
4 | @EnvironmentObject var themeManager: ThemeManager
5 |
6 | let onApply: () -> Void
7 | let disabled: Bool
8 |
9 | var body: some View {
10 | Section {
11 | VStack(spacing: 16) {
12 | HStack(alignment: .top, spacing: 12) {
13 | Image(systemName: "exclamationmark.triangle.fill")
14 | .font(.title2)
15 | .foregroundColor(.yellow)
16 |
17 | Text(
18 | "We found an issue where the schedule got destroyed, press apply to setup the schedule again. Alternatively setup a new schedule below."
19 | )
20 | .font(.subheadline)
21 | .foregroundColor(.primary)
22 | .fixedSize(horizontal: false, vertical: true)
23 | }
24 | .frame(maxWidth: .infinity, alignment: .leading)
25 |
26 | Button(action: onApply) {
27 | Text("Fix Schedule")
28 | .font(.headline)
29 | .foregroundColor(.white)
30 | .frame(maxWidth: .infinity)
31 | .padding(.vertical, 12)
32 | .background(themeManager.themeColor)
33 | .cornerRadius(10)
34 | }
35 | .disabled(disabled)
36 | .opacity(disabled ? 0.5 : 1.0)
37 | }
38 | .padding(.vertical, 8)
39 | }
40 | }
41 | }
42 |
43 | #Preview {
44 | Form {
45 | ScheduleWarningPrompt(
46 | onApply: { print("Apply tapped") },
47 | disabled: false
48 | )
49 | }
50 | }
51 |
52 | #Preview("Disabled") {
53 | Form {
54 | ScheduleWarningPrompt(
55 | onApply: { print("Apply tapped") },
56 | disabled: true
57 | )
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/Foqos/Components/BlockedProfileCards/ProfileStatsRow.swift:
--------------------------------------------------------------------------------
1 | import FamilyControls
2 | import SwiftUI
3 |
4 | struct ProfileStatsRow: View {
5 | let selectedActivity: FamilyActivitySelection
6 | let sessionCount: Int
7 | let domainsCount: Int
8 |
9 | var body: some View {
10 | HStack(spacing: 16) {
11 | // Apps count
12 | VStack(alignment: .leading, spacing: 2) {
13 | Text("Apps & Categories")
14 | .font(.caption)
15 | .foregroundColor(.secondary)
16 |
17 | Text(
18 | "\(FamilyActivityUtil.countSelectedActivities(selectedActivity))"
19 | )
20 | .font(.subheadline)
21 | .fontWeight(.semibold)
22 | }
23 |
24 | Divider()
25 | .frame(height: 24)
26 |
27 | VStack(alignment: .leading, spacing: 2) {
28 | Text("Domains")
29 | .font(.caption)
30 | .foregroundColor(.secondary)
31 |
32 | Text(
33 | "\(domainsCount)"
34 | )
35 | .font(.subheadline)
36 | .fontWeight(.semibold)
37 | }
38 |
39 | Divider()
40 | .frame(height: 24)
41 |
42 | // Active sessions
43 | VStack(alignment: .leading, spacing: 2) {
44 | Text("Total Sessions")
45 | .font(.caption)
46 | .foregroundColor(.secondary)
47 |
48 | Text(
49 | sessionCount.description
50 | .localizedLowercase
51 | )
52 | .font(.subheadline)
53 | .fontWeight(.semibold)
54 | }
55 | }
56 | }
57 | }
58 |
59 | #Preview {
60 | ProfileStatsRow(
61 | selectedActivity: FamilyActivitySelection(),
62 | sessionCount: 12,
63 | domainsCount: 12
64 | )
65 | .padding()
66 | .background(Color(.systemGroupedBackground))
67 | }
68 |
--------------------------------------------------------------------------------
/foqos.xcodeproj/xcuserdata/awaseem.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | FoqosDeviceMonitor.xcscheme_^#shared#^_
8 |
9 | orderHint
10 | 2
11 |
12 | FoqosShieldConfig.xcscheme_^#shared#^_
13 |
14 | orderHint
15 | 3
16 |
17 | FoqosWidgetExtension.xcscheme_^#shared#^_
18 |
19 | orderHint
20 | 1
21 |
22 | LiveSessionTrackerExtension.xcscheme_^#shared#^_
23 |
24 | orderHint
25 | 1
26 |
27 | foqos.xcscheme_^#shared#^_
28 |
29 | orderHint
30 | 0
31 |
32 | foqosblockedtracker.xcscheme_^#shared#^_
33 |
34 | orderHint
35 | 1
36 |
37 |
38 | SuppressBuildableAutocreation
39 |
40 | 801713092DE6C78C00B77FE1
41 |
42 | primary
43 |
44 |
45 | 801B20942CB363A10073E9E2
46 |
47 | primary
48 |
49 |
50 | 801B20A52CB363A20073E9E2
51 |
52 | primary
53 |
54 |
55 | 801B20AF2CB363A20073E9E2
56 |
57 | primary
58 |
59 |
60 | 80FF388C2D80DB540032BC5E
61 |
62 | primary
63 |
64 |
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## Obj-C/Swift specific
9 | *.hmap
10 |
11 | ## App packaging
12 | *.ipa
13 | *.dSYM.zip
14 | *.dSYM
15 |
16 | ## Playgrounds
17 | timeline.xctimeline
18 | playground.xcworkspace
19 |
20 | # Swift Package Manager
21 | #
22 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
23 | # Packages/
24 | # Package.pins
25 | # Package.resolved
26 | # *.xcodeproj
27 | #
28 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
29 | # hence it is not needed unless you have added a package configuration file to your project
30 | # .swiftpm
31 |
32 | .build/
33 |
34 | # CocoaPods
35 | #
36 | # We recommend against adding the Pods directory to your .gitignore. However
37 | # you should judge for yourself, the pros and cons are mentioned at:
38 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
39 | #
40 | # Pods/
41 | #
42 | # Add this line if you want to avoid checking in source code from the Xcode workspace
43 | # *.xcworkspace
44 |
45 | # Carthage
46 | #
47 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
48 | # Carthage/Checkouts
49 |
50 | Carthage/Build/
51 |
52 | # fastlane
53 | #
54 | # It is recommended to not store the screenshots in the git repo.
55 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
56 | # For more information about the recommended setup visit:
57 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
58 |
59 | fastlane/report.xml
60 | fastlane/Preview.html
61 | fastlane/screenshots/**/*.png
62 | fastlane/test_output
63 |
--------------------------------------------------------------------------------
/Foqos/Views/SettingsView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct SettingsView: View {
4 | @Environment(\.dismiss) private var dismiss
5 | @EnvironmentObject var themeManager: ThemeManager
6 |
7 | var body: some View {
8 | NavigationStack {
9 | Form {
10 | Section("Theme") {
11 | HStack {
12 | Image(systemName: "paintpalette.fill")
13 | .foregroundStyle(themeManager.themeColor)
14 | .font(.title3)
15 |
16 | VStack(alignment: .leading, spacing: 2) {
17 | Text("Appearance")
18 | .font(.headline)
19 | Text("Customize the look of your app")
20 | .font(.caption)
21 | .foregroundStyle(.secondary)
22 | }
23 | }
24 | .padding(.vertical, 8)
25 |
26 | Picker("Theme Color", selection: $themeManager.selectedColorName) {
27 | ForEach(ThemeManager.availableColors, id: \.name) { colorOption in
28 | HStack {
29 | Circle()
30 | .fill(colorOption.color)
31 | .frame(width: 20, height: 20)
32 | Text(colorOption.name)
33 | }
34 | .tag(colorOption.name)
35 | }
36 | }
37 | .onChange(of: themeManager.selectedColorName) { _, _ in
38 | UIImpactFeedbackGenerator(style: .medium).impactOccurred()
39 | }
40 | }
41 | }
42 | .navigationTitle("Settings")
43 | .toolbar {
44 | ToolbarItem(placement: .topBarLeading) {
45 | Button(action: { dismiss() }) {
46 | Image(systemName: "xmark")
47 | }
48 | .accessibilityLabel("Close")
49 | }
50 | }
51 | }
52 | }
53 | }
54 |
55 | #Preview {
56 | SettingsView()
57 | .environmentObject(ThemeManager.shared)
58 | }
59 |
--------------------------------------------------------------------------------
/Foqos/Components/BlockedProfileView/BlockedProfileScheduleSelector.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct BlockedProfileScheduleSelector: View {
4 | @EnvironmentObject var themeManager: ThemeManager
5 |
6 | var schedule: BlockedProfileSchedule
7 | var buttonAction: () -> Void
8 | var disabled: Bool = false
9 | var disabledText: String?
10 |
11 | private var buttonText: String { "Set schedule" }
12 |
13 | private var daysCount: Int { schedule.days.count }
14 |
15 | var body: some View {
16 | Button(action: buttonAction) {
17 | HStack {
18 | Text(buttonText)
19 | .foregroundStyle(themeManager.themeColor)
20 | Spacer()
21 | Image(systemName: "chevron.right")
22 | .foregroundStyle(.gray)
23 | }
24 | }
25 | .disabled(disabled)
26 |
27 | if let disabledText = disabledText, disabled {
28 | Text(disabledText)
29 | .foregroundStyle(.red)
30 | .padding(.top, 4)
31 | .font(.caption)
32 | } else if daysCount == 0 {
33 | Text("No Schedule Set")
34 | .foregroundStyle(.gray)
35 | } else {
36 | Text(schedule.summaryText)
37 | .font(.footnote)
38 | .foregroundStyle(.gray)
39 | .padding(.top, 4)
40 | }
41 | }
42 | }
43 |
44 | #Preview {
45 | VStack(spacing: 20) {
46 | BlockedProfileScheduleSelector(
47 | schedule: .init(
48 | days: [.monday, .wednesday, .friday], startHour: 9, startMinute: 0, endHour: 17,
49 | endMinute: 0, updatedAt: Date()),
50 | buttonAction: {}
51 | )
52 |
53 | BlockedProfileScheduleSelector(
54 | schedule: .init(
55 | days: [], startHour: 9, startMinute: 0, endHour: 17, endMinute: 0, updatedAt: Date()),
56 | buttonAction: {},
57 | disabled: true,
58 | disabledText: "Disable the current session to edit schedule"
59 | )
60 | }
61 | .padding()
62 | }
63 |
--------------------------------------------------------------------------------
/Foqos/Components/BlockedProfileCards/StrategyInfoView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct StrategyInfoView: View {
4 | @EnvironmentObject var themeManager: ThemeManager
5 |
6 | let strategyId: String?
7 |
8 | // Get blocking strategy name
9 | private var blockingStrategyName: String {
10 | guard let strategyId = strategyId else { return "None" }
11 | return StrategyManager.getStrategyFromId(id: strategyId).name
12 | }
13 |
14 | // Get blocking strategy icon
15 | private var blockingStrategyIcon: String {
16 | guard let strategyId = strategyId else {
17 | return "questionmark.circle.fill"
18 | }
19 | return StrategyManager.getStrategyFromId(id: strategyId).iconType
20 | }
21 |
22 | // Get blocking strategy color
23 | private var blockingStrategyColor: Color {
24 | guard let strategyId = strategyId else {
25 | return .gray
26 | }
27 | return StrategyManager.getStrategyFromId(id: strategyId).color
28 | }
29 |
30 | var body: some View {
31 | HStack {
32 | Image(systemName: blockingStrategyIcon)
33 | .foregroundColor(themeManager.themeColor)
34 | .font(.system(size: 13))
35 | .frame(width: 28, height: 28)
36 | .background(
37 | Circle()
38 | .fill(
39 | themeManager.themeColor.opacity(0.15)
40 | )
41 | )
42 |
43 | VStack(alignment: .leading, spacing: 2) {
44 | Text(blockingStrategyName)
45 | .foregroundColor(.primary)
46 | .font(.subheadline)
47 | .fontWeight(.medium)
48 | }
49 | }
50 | }
51 | }
52 |
53 | #Preview {
54 | VStack(spacing: 20) {
55 | StrategyInfoView(strategyId: NFCBlockingStrategy.id)
56 | StrategyInfoView(strategyId: QRCodeBlockingStrategy.id)
57 | StrategyInfoView(strategyId: nil)
58 | }
59 | .padding()
60 | .background(Color(.systemGroupedBackground))
61 | }
62 |
--------------------------------------------------------------------------------
/Foqos/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "AppIcon~ios-marketing.png",
5 | "idiom" : "universal",
6 | "platform" : "ios",
7 | "size" : "1024x1024"
8 | },
9 | {
10 | "appearances" : [
11 | {
12 | "appearance" : "luminosity",
13 | "value" : "dark"
14 | }
15 | ],
16 | "filename" : "AppIcon~ios-marketing 1.png",
17 | "idiom" : "universal",
18 | "platform" : "ios",
19 | "size" : "1024x1024"
20 | },
21 | {
22 | "appearances" : [
23 | {
24 | "appearance" : "luminosity",
25 | "value" : "tinted"
26 | }
27 | ],
28 | "filename" : "AppIcon~ios-marketing 2.png",
29 | "idiom" : "universal",
30 | "platform" : "ios",
31 | "size" : "1024x1024"
32 | },
33 | {
34 | "idiom" : "mac",
35 | "scale" : "1x",
36 | "size" : "16x16"
37 | },
38 | {
39 | "idiom" : "mac",
40 | "scale" : "2x",
41 | "size" : "16x16"
42 | },
43 | {
44 | "idiom" : "mac",
45 | "scale" : "1x",
46 | "size" : "32x32"
47 | },
48 | {
49 | "idiom" : "mac",
50 | "scale" : "2x",
51 | "size" : "32x32"
52 | },
53 | {
54 | "idiom" : "mac",
55 | "scale" : "1x",
56 | "size" : "128x128"
57 | },
58 | {
59 | "idiom" : "mac",
60 | "scale" : "2x",
61 | "size" : "128x128"
62 | },
63 | {
64 | "idiom" : "mac",
65 | "scale" : "1x",
66 | "size" : "256x256"
67 | },
68 | {
69 | "idiom" : "mac",
70 | "scale" : "2x",
71 | "size" : "256x256"
72 | },
73 | {
74 | "idiom" : "mac",
75 | "scale" : "1x",
76 | "size" : "512x512"
77 | },
78 | {
79 | "idiom" : "mac",
80 | "scale" : "2x",
81 | "size" : "512x512"
82 | }
83 | ],
84 | "info" : {
85 | "author" : "xcode",
86 | "version" : 1
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/Foqos/Components/Debug/SelectedActivityDebugCard.swift:
--------------------------------------------------------------------------------
1 | import FamilyControls
2 | import SwiftUI
3 |
4 | struct SelectedActivityDebugCard: View {
5 | let selection: FamilyActivitySelection
6 |
7 | var body: some View {
8 | VStack(alignment: .leading, spacing: 8) {
9 | // Counts
10 | Group {
11 | DebugRow(label: "Applications Count", value: "\(selection.applications.count)")
12 | DebugRow(label: "Categories Count", value: "\(selection.categories.count)")
13 | DebugRow(label: "Web Domains Count", value: "\(selection.webDomains.count)")
14 | }
15 |
16 | // Applications Detail
17 | if !selection.applications.isEmpty {
18 | Divider()
19 | VStack(alignment: .leading, spacing: 4) {
20 | Text("Applications:")
21 | .font(.caption)
22 | .foregroundColor(.secondary)
23 | Text("\(selection.applications.count) app(s) selected")
24 | .font(.caption)
25 | .foregroundColor(.primary)
26 | }
27 | }
28 |
29 | // Categories Detail
30 | if !selection.categories.isEmpty {
31 | Divider()
32 | VStack(alignment: .leading, spacing: 4) {
33 | Text("Categories:")
34 | .font(.caption)
35 | .foregroundColor(.secondary)
36 | Text("\(selection.categories.count) category(ies) selected")
37 | .font(.caption)
38 | .foregroundColor(.primary)
39 | }
40 | }
41 |
42 | // Web Domains Detail
43 | if !selection.webDomains.isEmpty {
44 | Divider()
45 | VStack(alignment: .leading, spacing: 4) {
46 | Text("Web Domains:")
47 | .font(.caption)
48 | .foregroundColor(.secondary)
49 | Text("\(selection.webDomains.count) domain(s) selected")
50 | .font(.caption)
51 | .foregroundColor(.primary)
52 | }
53 | }
54 | }
55 | }
56 | }
57 |
58 | #Preview {
59 | SelectedActivityDebugCard(selection: FamilyActivitySelection())
60 | .padding()
61 | }
62 |
--------------------------------------------------------------------------------
/Foqos/Components/BlockedProfileView/BlockedProfileDomainSelector.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct BlockedProfileDomainSelector: View {
4 | @EnvironmentObject var themeManager: ThemeManager
5 |
6 | var domains: [String]
7 | var buttonAction: () -> Void
8 | var allowMode: Bool = false
9 | var disabled: Bool = false
10 | var disabledText: String?
11 |
12 | private var title: String {
13 | return allowMode ? "Allowed" : "Blocked"
14 | }
15 |
16 | private var domainCount: Int {
17 | return domains.count
18 | }
19 |
20 | private var buttonText: String {
21 | return allowMode
22 | ? "Select Domains to Allow"
23 | : "Select Domains to Restrict"
24 | }
25 |
26 | var body: some View {
27 | Button(action: buttonAction) {
28 | HStack {
29 | Text(buttonText)
30 | .foregroundStyle(themeManager.themeColor)
31 | Spacer()
32 | Image(systemName: "chevron.right")
33 | .foregroundStyle(.gray)
34 | }
35 | }
36 | .disabled(disabled)
37 |
38 | if let disabledText = disabledText, disabled {
39 | Text(disabledText)
40 | .foregroundStyle(.red)
41 | .padding(.top, 4)
42 | .font(.caption)
43 | } else if domainCount == 0 {
44 | Text("No domains selected")
45 | .foregroundStyle(.gray)
46 | } else {
47 | Text("\(domainCount) \(domainCount == 1 ? "domain" : "domains") selected")
48 | .font(.footnote)
49 | .foregroundStyle(.gray)
50 | .padding(.top, 4)
51 | }
52 | }
53 | }
54 |
55 | #Preview {
56 | VStack(spacing: 20) {
57 | BlockedProfileDomainSelector(
58 | domains: ["example.com", "test.org"],
59 | buttonAction: {}
60 | )
61 |
62 | BlockedProfileDomainSelector(
63 | domains: [],
64 | buttonAction: {},
65 | allowMode: true
66 | )
67 |
68 | BlockedProfileDomainSelector(
69 | domains: ["example.com"],
70 | buttonAction: {},
71 | disabled: true,
72 | disabledText: "Disable the current session to edit domains"
73 | )
74 | }
75 | .padding()
76 | }
77 |
--------------------------------------------------------------------------------
/Foqos/Models/Timers/TimerActivityUtil.swift:
--------------------------------------------------------------------------------
1 | import DeviceActivity
2 |
3 | class TimerActivityUtil {
4 | static func startTimerActivity(for activity: DeviceActivityName) {
5 | let parts = getTimerParts(from: activity)
6 |
7 | guard let timerActivity = getTimerActivity(for: parts.deviceActivityId),
8 | let profile = getProfile(for: parts.profileId)
9 | else {
10 | return
11 | }
12 |
13 | timerActivity.start(for: profile)
14 | }
15 |
16 | static func stopTimerActivity(for activity: DeviceActivityName) {
17 | let parts = getTimerParts(from: activity)
18 |
19 | guard let timerActivity = getTimerActivity(for: parts.deviceActivityId),
20 | let profile = getProfile(for: parts.profileId)
21 | else {
22 | return
23 | }
24 |
25 | timerActivity.stop(for: profile)
26 | }
27 |
28 | private static func getTimerParts(from activity: DeviceActivityName) -> (
29 | deviceActivityId: String, profileId: String
30 | ) {
31 | let activityName = activity.rawValue
32 | let components = activityName.split(separator: ":")
33 |
34 | // For versions >= 1.24, the activity name format is "type:profileId"
35 | if components.count == 2 {
36 | return (deviceActivityId: String(components[0]), profileId: String(components[1]))
37 | }
38 |
39 | // For versions < 1.24, the activity name format is just "profileId" and only supports schedule timer activity
40 | // This is to support backward compatibility for older schedules
41 | return (deviceActivityId: ScheduleTimerActivity.id, profileId: activityName)
42 | }
43 |
44 | private static func getTimerActivity(for deviceActivityId: String) -> TimerActivity? {
45 | switch deviceActivityId {
46 | case ScheduleTimerActivity.id:
47 | return ScheduleTimerActivity()
48 | case BreakTimerActivity.id:
49 | return BreakTimerActivity()
50 | case StrategyTimerActivity.id:
51 | return StrategyTimerActivity()
52 | default:
53 | return nil
54 | }
55 | }
56 |
57 | private static func getProfile(for profileId: String) -> SharedData.ProfileSnapshot? {
58 | return SharedData.snapshot(for: profileId)
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/Foqos/Components/Intro/AnimatedIntroContainer.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct AnimatedIntroContainer: View {
4 | @State private var currentStep: Int = 0
5 | let onRequestAuthorization: () -> Void
6 |
7 | private let totalSteps = 3
8 |
9 | var body: some View {
10 | VStack(spacing: 0) {
11 | // Content area
12 | Group {
13 | switch currentStep {
14 | case 0:
15 | WelcomeIntroScreen()
16 | case 1:
17 | FeaturesIntroScreen()
18 | case 2:
19 | PermissionsIntroScreen()
20 | default:
21 | WelcomeIntroScreen()
22 | }
23 | }
24 | .transition(
25 | .asymmetric(
26 | insertion: .move(edge: .trailing).combined(with: .opacity),
27 | removal: .move(edge: .leading).combined(with: .opacity)
28 | )
29 | )
30 | .animation(.easeInOut(duration: 0.3), value: currentStep)
31 |
32 | // Stepper
33 | IntroStepper(
34 | currentStep: currentStep,
35 | totalSteps: totalSteps,
36 | onNext: handleNext,
37 | onBack: handleBack,
38 | nextButtonTitle: getNextButtonTitle(),
39 | showBackButton: currentStep > 0
40 | )
41 | .animation(.easeInOut, value: currentStep)
42 | }
43 | .padding(.top, 10)
44 | }
45 |
46 | private func handleNext() {
47 | if currentStep < totalSteps - 1 {
48 | withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
49 | currentStep += 1
50 | }
51 | } else {
52 | // Last step - request authorization
53 | onRequestAuthorization()
54 | }
55 | }
56 |
57 | private func handleBack() {
58 | if currentStep > 0 {
59 | withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
60 | currentStep -= 1
61 | }
62 | }
63 | }
64 |
65 | private func getNextButtonTitle() -> String {
66 | switch currentStep {
67 | case totalSteps - 1:
68 | return "Allow Screen Time Access"
69 | default:
70 | return "Continue"
71 | }
72 | }
73 | }
74 |
75 | #Preview {
76 | AnimatedIntroContainer(
77 | onRequestAuthorization: {
78 | print("Request authorization")
79 | }
80 | )
81 | }
82 |
--------------------------------------------------------------------------------
/Foqos/Tip for developer.storekit:
--------------------------------------------------------------------------------
1 | {
2 | "appPolicies" : {
3 | "eula" : "",
4 | "policies" : [
5 | {
6 | "locale" : "en_US",
7 | "policyText" : "",
8 | "policyURL" : ""
9 | }
10 | ]
11 | },
12 | "identifier" : "D1562A97",
13 | "nonRenewingSubscriptions" : [
14 |
15 | ],
16 | "products" : [
17 | {
18 | "displayPrice" : "1.99",
19 | "familyShareable" : false,
20 | "internalID" : "7808C96D",
21 | "localizations" : [
22 | {
23 | "description" : "",
24 | "displayName" : "",
25 | "locale" : "en_US"
26 | }
27 | ],
28 | "productID" : "tip_developer_support",
29 | "referenceName" : "Tip for developer ",
30 | "type" : "NonConsumable"
31 | }
32 | ],
33 | "settings" : {
34 | "_failTransactionsEnabled" : false,
35 | "_locale" : "en_US",
36 | "_storefront" : "USA",
37 | "_storeKitErrors" : [
38 | {
39 | "current" : null,
40 | "enabled" : false,
41 | "name" : "Load Products"
42 | },
43 | {
44 | "current" : null,
45 | "enabled" : false,
46 | "name" : "Purchase"
47 | },
48 | {
49 | "current" : null,
50 | "enabled" : false,
51 | "name" : "Verification"
52 | },
53 | {
54 | "current" : null,
55 | "enabled" : false,
56 | "name" : "App Store Sync"
57 | },
58 | {
59 | "current" : null,
60 | "enabled" : false,
61 | "name" : "Subscription Status"
62 | },
63 | {
64 | "current" : null,
65 | "enabled" : false,
66 | "name" : "App Transaction"
67 | },
68 | {
69 | "current" : null,
70 | "enabled" : false,
71 | "name" : "Manage Subscriptions Sheet"
72 | },
73 | {
74 | "current" : null,
75 | "enabled" : false,
76 | "name" : "Refund Request Sheet"
77 | },
78 | {
79 | "current" : null,
80 | "enabled" : false,
81 | "name" : "Offer Code Redeem Sheet"
82 | }
83 | ]
84 | },
85 | "subscriptionGroups" : [
86 |
87 | ],
88 | "version" : {
89 | "major" : 4,
90 | "minor" : 0
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/Foqos/Models/Strategies/NFCManualBlockingStrategy.swift:
--------------------------------------------------------------------------------
1 | import SwiftData
2 | import SwiftUI
3 |
4 | class NFCManualBlockingStrategy: BlockingStrategy {
5 | static var id: String = "NFCManualBlockingStrategy"
6 |
7 | var name: String = "NFC + Manual"
8 | var description: String = "Block manually, but unblock by using any NFC tag"
9 | var iconType: String = "badge.plus.radiowaves.forward"
10 | var color: Color = .yellow
11 |
12 | var onSessionCreation: ((SessionStatus) -> Void)?
13 | var onErrorMessage: ((String) -> Void)?
14 |
15 | private let nfcScanner: NFCScannerUtil = NFCScannerUtil()
16 | private let appBlocker: AppBlockerUtil = AppBlockerUtil()
17 |
18 | func getIdentifier() -> String {
19 | return NFCManualBlockingStrategy.id
20 | }
21 |
22 | func startBlocking(
23 | context: ModelContext,
24 | profile: BlockedProfiles,
25 | forceStart: Bool?
26 | ) -> (any View)? {
27 | self.appBlocker.activateRestrictions(for: BlockedProfiles.getSnapshot(for: profile))
28 |
29 | let activeSession =
30 | BlockedProfileSession
31 | .createSession(
32 | in: context,
33 | // Manually starting sessions, since nothing was scanned to start there is no tag to store for each session
34 | withTag: ManualBlockingStrategy.id,
35 | withProfile: profile,
36 | forceStart: forceStart ?? false
37 | )
38 |
39 | self.onSessionCreation?(.started(activeSession))
40 |
41 | return nil
42 | }
43 |
44 | func stopBlocking(
45 | context: ModelContext,
46 | session: BlockedProfileSession
47 | ) -> (any View)? {
48 | nfcScanner.onTagScanned = { tag in
49 | let tag = tag.url ?? tag.id
50 |
51 | if let physicalUnblockNFCTagId = session.blockedProfile.physicalUnblockNFCTagId,
52 | physicalUnblockNFCTagId != tag
53 | {
54 | self.onErrorMessage?(
55 | "This NFC tag is not allowed to unblock this profile. Physical unblock setting is on for this profile"
56 | )
57 | return
58 | }
59 |
60 | session.endSession()
61 | self.appBlocker.deactivateRestrictions()
62 |
63 | self.onSessionCreation?(.ended(session.blockedProfile))
64 | }
65 |
66 | nfcScanner.scan(profileName: session.blockedProfile.name)
67 |
68 | return nil
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/Foqos/Utils/AppBlockerUtil.swift:
--------------------------------------------------------------------------------
1 | import ManagedSettings
2 | import SwiftUI
3 |
4 | class AppBlockerUtil {
5 | let store = ManagedSettingsStore(
6 | named: ManagedSettingsStore.Name("foqosAppRestrictions")
7 | )
8 |
9 | func activateRestrictions(for profile: SharedData.ProfileSnapshot) {
10 | print("Starting restrictions...")
11 |
12 | let selection = profile.selectedActivity
13 | let allowOnlyApps = profile.enableAllowMode
14 | let allowOnlyDomains = profile.enableAllowModeDomains
15 | let strict = profile.enableStrictMode
16 | let enableSafariBlocking = profile.enableSafariBlocking
17 | let domains = getWebDomains(from: profile)
18 |
19 | let applicationTokens = selection.applicationTokens
20 | let categoriesTokens = selection.categoryTokens
21 | let webTokens = selection.webDomainTokens
22 |
23 | if allowOnlyApps {
24 | store.shield.applicationCategories =
25 | .all(except: applicationTokens)
26 |
27 | if enableSafariBlocking {
28 | store.shield.webDomainCategories = .all(except: webTokens)
29 | }
30 |
31 | } else {
32 | store.shield.applications = applicationTokens
33 | store.shield.applicationCategories = .specific(categoriesTokens)
34 |
35 | if enableSafariBlocking {
36 | store.shield.webDomainCategories = .specific(categoriesTokens)
37 | store.shield.webDomains = webTokens
38 | }
39 | }
40 |
41 | if allowOnlyDomains {
42 | store.webContent.blockedByFilter = .all(except: domains)
43 | } else {
44 | store.webContent.blockedByFilter = .specific(domains)
45 | }
46 |
47 | store.application.denyAppRemoval = strict
48 | }
49 |
50 | func deactivateRestrictions() {
51 | print("Stoping restrictions...")
52 |
53 | store.shield.applications = nil
54 | store.shield.applicationCategories = nil
55 | store.shield.webDomains = nil
56 | store.shield.webDomainCategories = nil
57 |
58 | store.application.denyAppRemoval = false
59 |
60 | store.webContent.blockedByFilter = nil
61 |
62 | store.clearAllSettings()
63 | }
64 |
65 | func getWebDomains(from profile: SharedData.ProfileSnapshot) -> Set {
66 | if let domains = profile.domains {
67 | return Set(domains.map { WebDomain(domain: $0) })
68 | }
69 |
70 | return []
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Foqos/Models/Timers/StrategyTimerActivity.swift:
--------------------------------------------------------------------------------
1 | import DeviceActivity
2 | import OSLog
3 |
4 | private let log: Logger = Logger(subsystem: "com.foqos.monitor", category: StrategyTimerActivity.id)
5 |
6 | class StrategyTimerActivity: TimerActivity {
7 | static var id: String = "StrategyTimerActivity"
8 |
9 | private let appBlocker = AppBlockerUtil()
10 |
11 | func getDeviceActivityName(from profileId: String) -> DeviceActivityName {
12 | return DeviceActivityName(rawValue: "\(StrategyTimerActivity.id):\(profileId)")
13 | }
14 |
15 | func getAllStrategyTimerActivities(from activities: [DeviceActivityName]) -> [DeviceActivityName]
16 | {
17 | return activities.filter { $0.rawValue.starts(with: StrategyTimerActivity.id) }
18 | }
19 |
20 | func start(for profile: SharedData.ProfileSnapshot) {
21 | let profileId = profile.id.uuidString
22 |
23 | log.info("Start strategy timer activity for \(profileId), profile: \(profileId)")
24 |
25 | if let activeSession = SharedData.getActiveSharedSession(),
26 | activeSession.blockedProfileId != profile.id
27 | {
28 | log.info(
29 | "Start strategy timer activity for \(profileId), active session profile does not match device activity profile, not continuing"
30 | )
31 | return
32 | }
33 |
34 | // No need to create a new active session since this is started in the app itself and session already exists
35 | // Start restrictions
36 | appBlocker.activateRestrictions(for: profile)
37 | }
38 |
39 | func stop(for profile: SharedData.ProfileSnapshot) {
40 | let profileId = profile.id.uuidString
41 |
42 | guard let activeSession = SharedData.getActiveSharedSession() else {
43 | log.info("Stop strategy timer activity for \(profileId), no active session found")
44 | return
45 | }
46 |
47 | // Check to make sure the active session is the same as the profile before disabling restrictions
48 | if activeSession.blockedProfileId != profile.id {
49 | log.info(
50 | "Stop strategy timer activity for \(profileId), active session profile does not match device activity profile"
51 | )
52 | return
53 | }
54 |
55 | // End restrictions
56 | appBlocker.deactivateRestrictions()
57 |
58 | // End the active strategy session
59 | SharedData.endActiveSharedSession()
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/Foqos/Models/Strategies/QRManualBlockingStrategy.swift:
--------------------------------------------------------------------------------
1 | import CodeScanner
2 | import SwiftData
3 | import SwiftUI
4 |
5 | class QRManualBlockingStrategy: BlockingStrategy {
6 | static var id: String = "QRManualBlockingStrategy"
7 |
8 | var name: String = "QR + Manual"
9 | var description: String = "Block manually, but unblock by using any QR/Barcode code"
10 | var iconType: String = "bolt.square"
11 | var color: Color = .pink
12 |
13 | var onSessionCreation: ((SessionStatus) -> Void)?
14 | var onErrorMessage: ((String) -> Void)?
15 |
16 | private let appBlocker: AppBlockerUtil = AppBlockerUtil()
17 |
18 | func getIdentifier() -> String {
19 | return QRManualBlockingStrategy.id
20 | }
21 |
22 | func startBlocking(
23 | context: ModelContext,
24 | profile: BlockedProfiles,
25 | forceStart: Bool?
26 | ) -> (any View)? {
27 | self.appBlocker.activateRestrictions(for: BlockedProfiles.getSnapshot(for: profile))
28 |
29 | let activeSession =
30 | BlockedProfileSession
31 | .createSession(
32 | in: context,
33 | withTag: ManualBlockingStrategy.id,
34 | withProfile: profile,
35 | forceStart: forceStart ?? false
36 | )
37 |
38 | self.onSessionCreation?(.started(activeSession))
39 |
40 | return nil
41 | }
42 |
43 | func stopBlocking(
44 | context: ModelContext,
45 | session: BlockedProfileSession
46 | ) -> (any View)? {
47 | return LabeledCodeScannerView(
48 | heading: "Scan to stop",
49 | subtitle: "Point your camera at a QR code to deactivate a profile."
50 | ) { result in
51 | switch result {
52 | case .success(let result):
53 | let tag = result.string
54 |
55 | if let physicalUnblockQRCodeId = session.blockedProfile.physicalUnblockQRCodeId,
56 | physicalUnblockQRCodeId != tag
57 | {
58 | self.onErrorMessage?(
59 | "This QR code is not allowed to unblock this profile. Physical unblock setting is on for this profile"
60 | )
61 | return
62 | }
63 |
64 | session.endSession()
65 | self.appBlocker.deactivateRestrictions()
66 |
67 | self.onSessionCreation?(.ended(session.blockedProfile))
68 | case .failure(let error):
69 | self.onErrorMessage?(error.localizedDescription)
70 | }
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/Foqos/Components/Debug/ScheduleDebugCard.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct ScheduleDebugCard: View {
4 | let schedule: BlockedProfileSchedule
5 |
6 | var body: some View {
7 | VStack(alignment: .leading, spacing: 8) {
8 | // Status & Summary
9 | Group {
10 | DebugRow(label: "Is Active", value: "\(schedule.isActive)")
11 | DebugRow(label: "Summary", value: schedule.summaryText)
12 | DebugRow(label: "Updated At", value: DateFormatters.formatDate(schedule.updatedAt))
13 | }
14 |
15 | Divider()
16 |
17 | // Schedule Details
18 | Group {
19 | DebugRow(
20 | label: "Days",
21 | value: schedule.days.map { $0.name }.joined(separator: ", ")
22 | )
23 | DebugRow(
24 | label: "Start Time",
25 | value: "\(schedule.startHour):\(String(format: "%02d", schedule.startMinute))"
26 | )
27 | DebugRow(
28 | label: "End Time",
29 | value: "\(schedule.endHour):\(String(format: "%02d", schedule.endMinute))"
30 | )
31 | DebugRow(label: "Duration (seconds)", value: "\(schedule.totalDurationInSeconds)")
32 | }
33 |
34 | Divider()
35 |
36 | // Status Checks
37 | Group {
38 | DebugRow(label: "Is Today Scheduled", value: "\(schedule.isTodayScheduled())")
39 | DebugRow(label: "Older Than 15 Minutes", value: "\(schedule.olderThan15Minutes())")
40 | }
41 | }
42 | }
43 | }
44 |
45 | #Preview {
46 | ScheduleDebugCard(
47 | schedule: BlockedProfileSchedule(
48 | days: [.monday, .tuesday, .wednesday, .thursday, .friday],
49 | startHour: 9,
50 | startMinute: 0,
51 | endHour: 17,
52 | endMinute: 30,
53 | updatedAt: Date()
54 | )
55 | )
56 | .padding()
57 | }
58 |
59 | #Preview("Weekend Schedule") {
60 | ScheduleDebugCard(
61 | schedule: BlockedProfileSchedule(
62 | days: [.saturday, .sunday],
63 | startHour: 10,
64 | startMinute: 0,
65 | endHour: 14,
66 | endMinute: 0,
67 | updatedAt: Date().addingTimeInterval(-3600)
68 | )
69 | )
70 | .padding()
71 | }
72 |
73 | #Preview("No Schedule") {
74 | ScheduleDebugCard(
75 | schedule: BlockedProfileSchedule(
76 | days: [],
77 | startHour: 0,
78 | startMinute: 0,
79 | endHour: 0,
80 | endMinute: 0,
81 | updatedAt: Date()
82 | )
83 | )
84 | .padding()
85 | }
86 |
--------------------------------------------------------------------------------
/Foqos/foqosApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // foqosApp.swift
3 | // foqos
4 | //
5 | // Created by Ali Waseem on 2024-10-06.
6 | //
7 |
8 | import AppIntents
9 | import BackgroundTasks
10 | import SwiftData
11 | import SwiftUI
12 |
13 | private let container: ModelContainer = {
14 | do {
15 | return try ModelContainer(
16 | for: BlockedProfileSession.self,
17 | BlockedProfiles.self
18 | )
19 | } catch {
20 | fatalError("Couldn’t create ModelContainer: \(error)")
21 | }
22 | }()
23 |
24 | @main
25 | struct foqosApp: App {
26 | @StateObject private var requestAuthorizer = RequestAuthorizer()
27 | @StateObject private var donationManager = TipManager()
28 | @StateObject private var navigationManager = NavigationManager()
29 | @StateObject private var nfcWriter = NFCWriter()
30 | @StateObject private var ratingManager = RatingManager()
31 |
32 | // Singletons for shared functionality
33 | @StateObject private var startegyManager = StrategyManager.shared
34 | @StateObject private var liveActivityManager = LiveActivityManager.shared
35 | @StateObject private var themeManager = ThemeManager.shared
36 |
37 | init() {
38 | TimersUtil.registerBackgroundTasks()
39 |
40 | let asyncDependency: @Sendable () async -> (ModelContainer) = {
41 | @MainActor in
42 | return container
43 | }
44 | AppDependencyManager.shared.add(
45 | key: "ModelContainer",
46 | dependency: asyncDependency
47 | )
48 | }
49 |
50 | var body: some Scene {
51 | WindowGroup {
52 | HomeView()
53 | .onOpenURL { url in
54 | handleUniversalLink(url)
55 | }
56 | .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) {
57 | userActivity in
58 | guard let url = userActivity.webpageURL else {
59 | return
60 | }
61 | handleUniversalLink(url)
62 |
63 | }
64 | .environmentObject(requestAuthorizer)
65 | .environmentObject(donationManager)
66 | .environmentObject(startegyManager)
67 | .environmentObject(navigationManager)
68 | .environmentObject(nfcWriter)
69 | .environmentObject(ratingManager)
70 | .environmentObject(liveActivityManager)
71 | .environmentObject(themeManager)
72 | }
73 | .modelContainer(container)
74 | }
75 |
76 | private func handleUniversalLink(_ url: URL) {
77 | navigationManager.handleLink(url)
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/FoqosWidget/ProfileSelectionIntent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileSelectionIntent.swift
3 | // FoqosWidget
4 | //
5 | // Created by Ali Waseem on 2025-03-11.
6 | //
7 |
8 | import AppIntents
9 | import Foundation
10 |
11 | // MARK: - Profile Entity for Widget Configuration
12 | struct WidgetProfileEntity: AppEntity {
13 | let id: String
14 | let name: String
15 |
16 | init(id: String, name: String) {
17 | self.id = id
18 | self.name = name
19 | }
20 |
21 | static var typeDisplayRepresentation = TypeDisplayRepresentation(
22 | name: "Profile"
23 | )
24 |
25 | static var defaultQuery = WidgetProfileQuery()
26 |
27 | var displayRepresentation: DisplayRepresentation {
28 | DisplayRepresentation(title: "\(name)")
29 | }
30 | }
31 |
32 | // MARK: - Profile Query for Widget Configuration
33 | struct WidgetProfileQuery: EntityQuery {
34 | func entities(for identifiers: [WidgetProfileEntity.ID]) async throws -> [WidgetProfileEntity] {
35 | let profileSnapshots = SharedData.profileSnapshots
36 | return identifiers.compactMap { id in
37 | guard let snapshot = profileSnapshots[id] else { return nil }
38 | return WidgetProfileEntity(id: id, name: snapshot.name)
39 | }
40 | }
41 |
42 | func suggestedEntities() async throws -> [WidgetProfileEntity] {
43 | let profileSnapshots = SharedData.profileSnapshots
44 | return profileSnapshots.map { (id, snapshot) in
45 | WidgetProfileEntity(id: id, name: snapshot.name)
46 | }.sorted { $0.name < $1.name }
47 | }
48 |
49 | func defaultResult() async -> WidgetProfileEntity? {
50 | return try? await suggestedEntities().first
51 | }
52 | }
53 |
54 | // MARK: - Widget Configuration Intent
55 | struct ProfileSelectionIntent: WidgetConfigurationIntent {
56 | static var title: LocalizedStringResource = "Select Profile"
57 | static var description = IntentDescription("Choose which profile to display in the widget")
58 |
59 | @Parameter(title: "Profile", description: "The profile to monitor in the widget")
60 | var profile: WidgetProfileEntity?
61 |
62 | @Parameter(
63 | title: "Quick Launch",
64 | description: "Launch the profile directly without navigating to the app")
65 | var useProfileURL: Bool?
66 |
67 | init() {
68 | self.useProfileURL = false
69 | }
70 |
71 | init(profile: WidgetProfileEntity?, useProfileURL: Bool = false) {
72 | self.profile = profile
73 | self.useProfileURL = useProfileURL
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Foqos/Components/Common/MultiStatCard.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct MultiStatCard: View {
4 | struct StatItem: Identifiable {
5 | let id = UUID()
6 | let title: String
7 | let valueText: String
8 | let systemImageName: String
9 | var iconColor: Color = .accentColor
10 | }
11 |
12 | let stats: [StatItem]
13 | var columns: Int = 2
14 |
15 | var body: some View {
16 | ZStack {
17 | RoundedRectangle(cornerRadius: 24, style: .continuous)
18 | .fill(.ultraThinMaterial)
19 |
20 | LazyVGrid(columns: gridColumns, alignment: .leading, spacing: 12) {
21 | ForEach(stats) { stat in
22 | HStack(alignment: .top, spacing: 12) {
23 | Image(systemName: stat.systemImageName)
24 | .font(.system(size: 20, weight: .semibold))
25 | .foregroundStyle(stat.iconColor)
26 | .frame(width: 24, height: 24)
27 |
28 | VStack(alignment: .leading, spacing: 4) {
29 | Text(stat.title)
30 | .font(.caption)
31 | .foregroundStyle(.secondary)
32 |
33 | Text(stat.valueText)
34 | .font(.title3)
35 | .fontWeight(.semibold)
36 | .foregroundStyle(.primary)
37 | }
38 | .frame(maxWidth: .infinity, alignment: .leading)
39 | }
40 | .frame(maxWidth: .infinity, alignment: .leading)
41 | }
42 | }
43 | .padding(16)
44 | }
45 | .frame(maxWidth: .infinity)
46 | }
47 |
48 | private var gridColumns: [GridItem] {
49 | Array(
50 | repeating: GridItem(.flexible(), spacing: 12, alignment: .topLeading), count: max(1, columns))
51 | }
52 | }
53 |
54 | #Preview {
55 | MultiStatCard(
56 | stats: [
57 | .init(
58 | title: "Total Focus Time", valueText: "3h 42m", systemImageName: "clock", iconColor: .blue),
59 | .init(
60 | title: "Average Session", valueText: "25m", systemImageName: "chart.bar", iconColor: .purple
61 | ),
62 | .init(
63 | title: "Longest Session", valueText: "1h 10m", systemImageName: "timer", iconColor: .green),
64 | .init(
65 | title: "Shortest Session", valueText: "5m", systemImageName: "hourglass", iconColor: .orange
66 | ),
67 | .init(
68 | title: "Total Sessions", valueText: "128", systemImageName: "list.number", iconColor: .blue),
69 | ],
70 | columns: 2
71 | )
72 | .background(Color(.systemGroupedBackground))
73 | }
74 |
--------------------------------------------------------------------------------
/Foqos/Models/Schedule.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | enum Weekday: Int, CaseIterable, Codable, Equatable {
4 | case sunday = 1
5 | case monday
6 | case tuesday
7 | case wednesday
8 | case thursday
9 | case friday
10 | case saturday
11 |
12 | var name: String {
13 | switch self {
14 | case .sunday: return "Sunday"
15 | case .monday: return "Monday"
16 | case .tuesday: return "Tuesday"
17 | case .wednesday: return "Wednesday"
18 | case .thursday: return "Thursday"
19 | case .friday: return "Friday"
20 | case .saturday: return "Saturday"
21 | }
22 | }
23 |
24 | var shortLabel: String {
25 | switch self {
26 | case .sunday: return "Su"
27 | case .monday: return "Mo"
28 | case .tuesday: return "Tu"
29 | case .wednesday: return "We"
30 | case .thursday: return "Th"
31 | case .friday: return "Fr"
32 | case .saturday: return "Sa"
33 | }
34 | }
35 | }
36 |
37 | struct BlockedProfileSchedule: Codable, Equatable {
38 | var days: [Weekday]
39 |
40 | var startHour: Int
41 | var startMinute: Int
42 | var endHour: Int
43 | var endMinute: Int
44 |
45 | var updatedAt: Date = Date()
46 |
47 | var isActive: Bool {
48 | return !days.isEmpty
49 | }
50 |
51 | var totalDurationInSeconds: Int {
52 | return (endHour - startHour) * 3600 + (endMinute - startMinute) * 60
53 | }
54 |
55 | var summaryText: String {
56 | guard isActive else { return "No Schedule Set" }
57 |
58 | let daysSummary =
59 | days
60 | .sorted { $0.rawValue < $1.rawValue }
61 | .map { $0.shortLabel }
62 | .joined(separator: " ")
63 |
64 | let start = formattedTimeString(hour24: startHour, minute: startMinute)
65 | let end = formattedTimeString(hour24: endHour, minute: endMinute)
66 |
67 | return "\(daysSummary) · \(start) - \(end)"
68 | }
69 |
70 | func isTodayScheduled(now: Date = Date(), calendar: Calendar = .current) -> Bool {
71 | guard isActive else { return false }
72 | let currentWeekdayRaw = calendar.component(.weekday, from: now)
73 | guard let today = Weekday(rawValue: currentWeekdayRaw) else { return false }
74 | return days.contains(today)
75 | }
76 |
77 | func olderThan15Minutes(now: Date = Date()) -> Bool {
78 | return now.timeIntervalSince(updatedAt) > 15 * 60
79 | }
80 |
81 | private func formattedTimeString(hour24: Int, minute: Int) -> String {
82 | var hour = hour24 % 12
83 | if hour == 0 { hour = 12 }
84 | let isPM = hour24 >= 12
85 | return "\(hour):\(String(format: "%02d", minute)) \(isPM ? "PM" : "AM")"
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/Foqos/Components/Debug/StrategyManagerDebugCard.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct StrategyManagerDebugCard: View {
4 | @ObservedObject var strategyManager: StrategyManager
5 |
6 | var body: some View {
7 | VStack(alignment: .leading, spacing: 8) {
8 | // Blocking Status
9 | Group {
10 | DebugRow(label: "Is Blocking", value: "\(strategyManager.isBlocking)")
11 | DebugRow(label: "Is Break Active", value: "\(strategyManager.isBreakActive)")
12 | DebugRow(label: "Is Break Available", value: "\(strategyManager.isBreakAvailable)")
13 | }
14 |
15 | Divider()
16 |
17 | // Timer Info
18 | Group {
19 | DebugRow(
20 | label: "Elapsed Time",
21 | value: DateFormatters.formatDuration(strategyManager.elapsedTime)
22 | )
23 | DebugRow(label: "Timer Active", value: "\(strategyManager.timer != nil)")
24 | }
25 |
26 | Divider()
27 |
28 | // UI State
29 | Group {
30 | DebugRow(
31 | label: "Show Custom Strategy View",
32 | value: "\(strategyManager.showCustomStrategyView)"
33 | )
34 | DebugRow(label: "Error Message", value: strategyManager.errorMessage ?? "nil")
35 | }
36 |
37 | Divider()
38 |
39 | // Emergency Unblocks
40 | DebugRow(
41 | label: "Emergency Unblocks Remaining",
42 | value: "\(strategyManager.getRemainingEmergencyUnblocks())"
43 | )
44 |
45 | Divider()
46 |
47 | // Available Strategies
48 | VStack(alignment: .leading, spacing: 4) {
49 | Text("Available Strategies:")
50 | .font(.caption)
51 | .foregroundColor(.secondary)
52 |
53 | ForEach(Array(StrategyManager.availableStrategies.enumerated()), id: \.offset) {
54 | _, strategy in
55 | Text("• \(strategy.getIdentifier())")
56 | .font(.caption)
57 | .foregroundColor(.primary)
58 | }
59 | }
60 | }
61 | }
62 | }
63 |
64 | #Preview {
65 | let strategyManager = StrategyManager.shared
66 |
67 | return StrategyManagerDebugCard(strategyManager: strategyManager)
68 | .padding()
69 | }
70 |
71 | #Preview("With Active Session") {
72 | let strategyManager = StrategyManager.shared
73 | strategyManager.elapsedTime = 3665 // 1 hour, 1 minute, 5 seconds
74 |
75 | return StrategyManagerDebugCard(strategyManager: strategyManager)
76 | .padding()
77 | }
78 |
79 | #Preview("With Error") {
80 | let strategyManager = StrategyManager.shared
81 | strategyManager.errorMessage = "Failed to scan NFC tag"
82 |
83 | return StrategyManagerDebugCard(strategyManager: strategyManager)
84 | .padding()
85 | }
86 |
--------------------------------------------------------------------------------
/Foqos/Models/Strategies/NFCBlockingStrategy.swift:
--------------------------------------------------------------------------------
1 | import SwiftData
2 | import SwiftUI
3 |
4 | class NFCBlockingStrategy: BlockingStrategy {
5 | static var id: String = "NFCBlockingStrategy"
6 |
7 | var name: String = "NFC Tags"
8 | var description: String =
9 | "Block and unblock profiles by using the exact same NFC tag"
10 | var iconType: String = "wave.3.right.circle.fill"
11 | var color: Color = .yellow
12 |
13 | var onSessionCreation: ((SessionStatus) -> Void)?
14 | var onErrorMessage: ((String) -> Void)?
15 |
16 | private let nfcScanner: NFCScannerUtil = NFCScannerUtil()
17 | private let appBlocker: AppBlockerUtil = AppBlockerUtil()
18 |
19 | func getIdentifier() -> String {
20 | return NFCBlockingStrategy.id
21 | }
22 |
23 | func startBlocking(
24 | context: ModelContext,
25 | profile: BlockedProfiles,
26 | forceStart: Bool?
27 | ) -> (any View)? {
28 | nfcScanner.onTagScanned = { tag in
29 | self.appBlocker.activateRestrictions(for: BlockedProfiles.getSnapshot(for: profile))
30 |
31 | let tag = tag.url ?? tag.id
32 | let activeSession =
33 | BlockedProfileSession
34 | .createSession(
35 | in: context,
36 | withTag: tag,
37 | withProfile: profile,
38 | forceStart: forceStart ?? false
39 | )
40 | self.onSessionCreation?(.started(activeSession))
41 | }
42 |
43 | nfcScanner.scan(profileName: profile.name)
44 |
45 | return nil
46 | }
47 |
48 | func stopBlocking(
49 | context: ModelContext,
50 | session: BlockedProfileSession
51 | ) -> (any View)? {
52 | nfcScanner.onTagScanned = { tag in
53 | let tag = tag.url ?? tag.id
54 |
55 | if let physicalUnblockNFCTagId = session.blockedProfile.physicalUnblockNFCTagId {
56 | // Physical unblock tag is set - only this specific tag can unblock
57 | if physicalUnblockNFCTagId != tag {
58 | self.onErrorMessage?(
59 | "This NFC tag is not allowed to unblock this profile. Physical unblock setting is on for this profile"
60 | )
61 | return
62 | }
63 | } else if !session.forceStarted && session.tag != tag {
64 | // No physical unblock tag - must use original session tag (unless force started)
65 | self.onErrorMessage?(
66 | "You must scan the original tag to stop focus"
67 | )
68 | return
69 | }
70 |
71 | session.endSession()
72 | self.appBlocker.deactivateRestrictions()
73 |
74 | self.onSessionCreation?(.ended(session.blockedProfile))
75 | }
76 |
77 | nfcScanner.scan(profileName: session.blockedProfile.name)
78 |
79 | return nil
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/Foqos/Models/Timers/BreakTimerActivity.swift:
--------------------------------------------------------------------------------
1 | import DeviceActivity
2 | import OSLog
3 |
4 | private let log = Logger(subsystem: "com.foqos.monitor", category: BreakTimerActivity.id)
5 |
6 | class BreakTimerActivity: TimerActivity {
7 | static var id: String = "BreakScheduleActivity"
8 |
9 | private let appBlocker = AppBlockerUtil()
10 |
11 | func getDeviceActivityName(from profileId: String) -> DeviceActivityName {
12 | return DeviceActivityName(rawValue: "\(BreakTimerActivity.id):\(profileId)")
13 | }
14 |
15 | func getAllBreakTimerActivities(from activities: [DeviceActivityName]) -> [DeviceActivityName] {
16 | return activities.filter { $0.rawValue.starts(with: BreakTimerActivity.id) }
17 | }
18 |
19 | func start(for profile: SharedData.ProfileSnapshot) {
20 | let profileId = profile.id.uuidString
21 |
22 | guard let activeSession = SharedData.getActiveSharedSession() else {
23 | log.info(
24 | "Start break timer activity for \(profileId), no active session found to start break")
25 | return
26 | }
27 |
28 | // Check to make sure the active session is the same as the profile before starting break
29 | if activeSession.blockedProfileId != profile.id {
30 | log.info(
31 | "Start break timer activity for \(profileId), active session profile does not match profile to start break"
32 | )
33 | return
34 | }
35 |
36 | // End restrictions for break
37 | appBlocker.deactivateRestrictions()
38 |
39 | // End the active scheduled session
40 | let now = Date()
41 | SharedData.setBreakStartTime(date: now)
42 | }
43 |
44 | func stop(for profile: SharedData.ProfileSnapshot) {
45 | let profileId = profile.id.uuidString
46 |
47 | guard let activeSession = SharedData.getActiveSharedSession() else {
48 | log.info(
49 | "Stop break timer activity for \(profileId), no active session found to stop break")
50 | return
51 | }
52 |
53 | // Check to make sure the active session is the same as the profile before stopping the break
54 | if activeSession.blockedProfileId != profile.id {
55 | log.info(
56 | "Stop break timer activity for \(profileId), active session profile does not match profile to start break"
57 | )
58 | return
59 | }
60 |
61 | // Check is a break is active before stopping the break
62 | if activeSession.breakStartTime != nil && activeSession.breakEndTime == nil {
63 | // Start restrictions again since break is ended
64 | appBlocker.activateRestrictions(for: profile)
65 |
66 | // Set the break end time
67 | let now = Date()
68 | SharedData.setBreakEndTime(date: now)
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/Foqos/Components/BlockedProfileView/BlockedProfileAppSelector.swift:
--------------------------------------------------------------------------------
1 | import FamilyControls
2 | import SwiftUI
3 |
4 | struct BlockedProfileAppSelector: View {
5 | @EnvironmentObject var themeManager: ThemeManager
6 |
7 | var selection: FamilyActivitySelection
8 | var buttonAction: () -> Void
9 | var allowMode: Bool = false
10 | var disabled: Bool = false
11 | var disabledText: String?
12 |
13 | private var title: String {
14 | return allowMode ? "Allowed" : "Blocked"
15 | }
16 |
17 | private var catAndAppCount: Int {
18 | return FamilyActivityUtil.countSelectedActivities(selection, allowMode: allowMode)
19 | }
20 |
21 | private var countDisplayText: String {
22 | return FamilyActivityUtil.getCountDisplayText(selection, allowMode: allowMode)
23 | }
24 |
25 | private var shouldShowWarning: Bool {
26 | return FamilyActivityUtil.shouldShowAllowModeWarning(selection, allowMode: allowMode)
27 | }
28 |
29 | private var buttonText: String {
30 | return allowMode
31 | ? "Select Apps to Allow"
32 | : "Select Apps to Restrict"
33 | }
34 |
35 | var body: some View {
36 |
37 | Button(action: buttonAction) {
38 | HStack {
39 | Text(buttonText)
40 | .foregroundStyle(themeManager.themeColor)
41 | Spacer()
42 | Image(systemName: "chevron.right")
43 | .foregroundStyle(.gray)
44 | }
45 | }
46 | .disabled(disabled)
47 |
48 | if let disabledText = disabledText, disabled {
49 | Text(disabledText)
50 | .foregroundStyle(.red)
51 | .padding(.top, 4)
52 | .font(.caption)
53 | } else if catAndAppCount == 0 {
54 | Text("No apps selected")
55 | .foregroundStyle(.gray)
56 | } else {
57 | VStack(alignment: .leading, spacing: 4) {
58 | Text("\(countDisplayText) selected")
59 | .font(.footnote)
60 | .foregroundStyle(.gray)
61 |
62 | if shouldShowWarning {
63 | Text("⚠️ Allow mode: Categories expand to individual apps (50 limit applies)")
64 | .font(.caption)
65 | .foregroundColor(.orange)
66 | .padding(.top, 4)
67 | }
68 | }
69 | .padding(.top, 4)
70 | }
71 |
72 | }
73 | }
74 |
75 | #Preview {
76 | BlockedProfileAppSelector(
77 | selection: FamilyActivitySelection(),
78 | buttonAction: {},
79 | disabled: true,
80 | disabledText: "Disable the current session to edit apps for blocking"
81 | )
82 |
83 | BlockedProfileAppSelector(
84 | selection: FamilyActivitySelection(),
85 | buttonAction: {},
86 | allowMode: true,
87 | disabled: true,
88 | disabledText: "Disable the current session to edit apps for blocking"
89 | )
90 | }
91 |
--------------------------------------------------------------------------------
/Foqos/Utils/DataExporter.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftData
3 |
4 | enum DataExportSortDirection: Hashable {
5 | case ascending
6 | case descending
7 | }
8 |
9 | enum DataExportTimeZone: Hashable {
10 | case utc
11 | case local
12 | }
13 |
14 | struct DataExporter {
15 | static func exportSessionsCSV(
16 | forProfileIDs profileIDs: [UUID],
17 | in context: ModelContext,
18 | sortDirection: DataExportSortDirection = .ascending,
19 | timeZone: DataExportTimeZone = .utc
20 | ) throws -> String {
21 | var lines: [String] = [
22 | "session_id,profile_name,start_time,end_time,break_start_time,break_end_time"
23 | ]
24 |
25 | if profileIDs.isEmpty {
26 | return lines.joined(separator: "\n")
27 | }
28 |
29 | let order: SortOrder = (sortDirection == .ascending) ? .forward : .reverse
30 | let descriptor = FetchDescriptor(
31 | predicate: #Predicate { session in
32 | profileIDs.contains(session.blockedProfile.id)
33 | },
34 | sortBy: [SortDescriptor(\.startTime, order: order)]
35 | )
36 |
37 | let sessions = try context.fetch(descriptor)
38 |
39 | let dateFormatter = makeISO8601Formatter(timeZone: timeZone)
40 | lines.reserveCapacity(sessions.count + 1)
41 | for session in sessions {
42 | let id = session.id
43 | let profileName = session.blockedProfile.name
44 | let start = dateFormatter.string(from: session.startTime)
45 | let end = session.endTime.map { dateFormatter.string(from: $0) } ?? ""
46 | let breakStart = session.breakStartTime.map { dateFormatter.string(from: $0) } ?? ""
47 | let breakEnd = session.breakEndTime.map { dateFormatter.string(from: $0) } ?? ""
48 |
49 | let row = [id, profileName, start, end, breakStart, breakEnd]
50 | .map { escapeCSVField($0) }
51 | .joined(separator: ",")
52 | lines.append(row)
53 | }
54 |
55 | return lines.joined(separator: "\n")
56 | }
57 |
58 | private static func escapeCSVField(_ field: String) -> String {
59 | if field.contains(",") || field.contains("\"") || field.contains("\n") {
60 | let escaped = field.replacingOccurrences(of: "\"", with: "\"\"")
61 | return "\"\(escaped)\""
62 | }
63 | return field
64 | }
65 |
66 | private static func makeISO8601Formatter(timeZone: DataExportTimeZone) -> ISO8601DateFormatter {
67 | let formatter = ISO8601DateFormatter()
68 | formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
69 | switch timeZone {
70 | case .utc:
71 | formatter.timeZone = TimeZone(secondsFromGMT: 0)
72 | case .local:
73 | formatter.timeZone = .current
74 | }
75 | return formatter
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/Foqos/Models/Strategies/NFCTimerBlockingStrategy.swift:
--------------------------------------------------------------------------------
1 | import SwiftData
2 | import SwiftUI
3 |
4 | class NFCTimerBlockingStrategy: BlockingStrategy {
5 | static var id: String = "NFCTimerBlockingStrategy"
6 |
7 | var name: String = "NFC + Timer"
8 | var description: String = "Block for a certain amount of minutes, unblock by using any NFC tag"
9 | var iconType: String = "alarm.waves.left.and.right"
10 | var color: Color = .mint
11 |
12 | var onSessionCreation: ((SessionStatus) -> Void)?
13 | var onErrorMessage: ((String) -> Void)?
14 |
15 | private let nfcScanner: NFCScannerUtil = NFCScannerUtil()
16 | private let appBlocker: AppBlockerUtil = AppBlockerUtil()
17 |
18 | func getIdentifier() -> String {
19 | return NFCTimerBlockingStrategy.id
20 | }
21 |
22 | func startBlocking(
23 | context: ModelContext,
24 | profile: BlockedProfiles,
25 | forceStart: Bool?
26 | ) -> (any View)? {
27 | return TimerDurationView(
28 | profileName: profile.name,
29 | onDurationSelected: { duration in
30 | if let strategyTimerData = StrategyTimerData.toData(from: duration) {
31 | // Store the timer data so that its selected for the next time the profile is started
32 | // This is also useful if the profile is started from the background like a shortcut or intent
33 | profile.strategyData = strategyTimerData
34 | profile.updatedAt = Date()
35 | BlockedProfiles.updateSnapshot(for: profile)
36 | try? context.save()
37 | }
38 |
39 | let activeSession = BlockedProfileSession.createSession(
40 | in: context,
41 | withTag: NFCTimerBlockingStrategy.id,
42 | withProfile: profile,
43 | forceStart: forceStart ?? false
44 | )
45 |
46 | DeviceActivityCenterUtil.startStrategyTimerActivity(for: profile)
47 |
48 | self.onSessionCreation?(.started(activeSession))
49 | }
50 | )
51 | }
52 |
53 | func stopBlocking(
54 | context: ModelContext,
55 | session: BlockedProfileSession
56 | ) -> (any View)? {
57 | nfcScanner.onTagScanned = { tag in
58 | let tag = tag.url ?? tag.id
59 |
60 | if let physicalUnblockNFCTagId = session.blockedProfile.physicalUnblockNFCTagId,
61 | physicalUnblockNFCTagId != tag
62 | {
63 | self.onErrorMessage?(
64 | "This NFC tag is not allowed to unblock this profile. Physical unblock setting is on for this profile"
65 | )
66 | return
67 | }
68 |
69 | session.endSession()
70 | self.appBlocker.deactivateRestrictions()
71 |
72 | self.onSessionCreation?(.ended(session.blockedProfile))
73 | }
74 |
75 | nfcScanner.scan(profileName: session.blockedProfile.name)
76 |
77 | return nil
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/Foqos/Utils/FamilyActivityUtil.swift:
--------------------------------------------------------------------------------
1 | import FamilyControls
2 | import Foundation
3 |
4 | /// Utility functions for working with FamilyActivitySelection
5 | struct FamilyActivityUtil {
6 |
7 | /// Counts the total number of selected activities (categories + applications + web domains)
8 | /// - Parameters:
9 | /// - selection: The FamilyActivitySelection to count
10 | /// - allowMode: Whether this is for allow mode (affects display but not actual count)
11 | /// - Returns: Total count of selected items
12 | /// - Note: This shows the count as displayed to users. In ALLOW mode, Apple internally expands
13 | /// categories to individual apps when enforcing the 50 app limit, so selecting a few
14 | /// categories may exceed the limit. In BLOCK mode, categories count as 1 item each.
15 | static func countSelectedActivities(_ selection: FamilyActivitySelection, allowMode: Bool = false)
16 | -> Int
17 | {
18 | // This count shows categories + apps + domains as displayed
19 | // IMPORTANT: In Allow mode, Apple enforces the 50 limit AFTER expanding categories to individual apps
20 | // In Block mode, categories count as 1 regardless of how many apps they contain
21 | return selection.categories.count + selection.applications.count + selection.webDomains.count
22 | }
23 |
24 | /// Gets display text for the count with appropriate warnings for allow mode
25 | /// - Parameters:
26 | /// - selection: The FamilyActivitySelection to display
27 | /// - allowMode: Whether this is for allow mode
28 | /// - Returns: Formatted display text with warnings if needed
29 | static func getCountDisplayText(_ selection: FamilyActivitySelection, allowMode: Bool = false)
30 | -> String
31 | {
32 | let count = countSelectedActivities(selection, allowMode: allowMode)
33 |
34 | return "\(count) items"
35 | }
36 |
37 | /// Determines if a warning should be shown for allow mode category selection
38 | /// - Parameters:
39 | /// - selection: The FamilyActivitySelection to check
40 | /// - allowMode: Whether this is for allow mode
41 | /// - Returns: True if warning should be shown
42 | static func shouldShowAllowModeWarning(
43 | _ selection: FamilyActivitySelection, allowMode: Bool = false
44 | ) -> Bool {
45 | return allowMode && selection.categories.count > 0
46 | }
47 |
48 | /// Gets a detailed breakdown of the selection for debugging/stats
49 | /// - Parameter selection: The FamilyActivitySelection to analyze
50 | /// - Returns: A breakdown of categories, apps, and domains
51 | static func getSelectionBreakdown(_ selection: FamilyActivitySelection) -> (
52 | categories: Int, applications: Int, webDomains: Int
53 | ) {
54 | return (
55 | categories: selection.categories.count,
56 | applications: selection.applications.count,
57 | webDomains: selection.webDomains.count
58 | )
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/Foqos/Models/Strategies/QRTimerBlockingStrategy.swift:
--------------------------------------------------------------------------------
1 | import SwiftData
2 | import SwiftUI
3 |
4 | class QRTimerBlockingStrategy: BlockingStrategy {
5 | static var id: String = "QRTimerBlockingStrategy"
6 |
7 | var name: String = "QR + Timer"
8 | var description: String = "Block for a certain amount of minutes, unblock by using any QR code"
9 | var iconType: String = "bolt.badge.clock"
10 | var color: Color = .mint
11 |
12 | var onSessionCreation: ((SessionStatus) -> Void)?
13 | var onErrorMessage: ((String) -> Void)?
14 |
15 | private let appBlocker: AppBlockerUtil = AppBlockerUtil()
16 |
17 | func getIdentifier() -> String {
18 | return QRTimerBlockingStrategy.id
19 | }
20 |
21 | func startBlocking(
22 | context: ModelContext,
23 | profile: BlockedProfiles,
24 | forceStart: Bool?
25 | ) -> (any View)? {
26 | return TimerDurationView(
27 | profileName: profile.name,
28 | onDurationSelected: { duration in
29 | if let strategyTimerData = StrategyTimerData.toData(from: duration) {
30 | // Store the timer data so that its selected for the next time the profile is started
31 | // This is also useful if the profile is started from the background like a shortcut or intent
32 | profile.strategyData = strategyTimerData
33 | profile.updatedAt = Date()
34 | BlockedProfiles.updateSnapshot(for: profile)
35 | try? context.save()
36 | }
37 |
38 | let activeSession = BlockedProfileSession.createSession(
39 | in: context,
40 | withTag: QRTimerBlockingStrategy.id,
41 | withProfile: profile,
42 | forceStart: forceStart ?? false
43 | )
44 |
45 | DeviceActivityCenterUtil.startStrategyTimerActivity(for: profile)
46 |
47 | self.onSessionCreation?(.started(activeSession))
48 | }
49 | )
50 | }
51 |
52 | func stopBlocking(
53 | context: ModelContext,
54 | session: BlockedProfileSession
55 | ) -> (any View)? {
56 | return LabeledCodeScannerView(
57 | heading: "Scan to stop",
58 | subtitle: "Point your camera at a QR code to deactivate a profile."
59 | ) { result in
60 | switch result {
61 | case .success(let result):
62 | let tag = result.string
63 |
64 | if let physicalUnblockQRCodeId = session.blockedProfile.physicalUnblockQRCodeId,
65 | physicalUnblockQRCodeId != tag
66 | {
67 | self.onErrorMessage?(
68 | "This QR code is not allowed to unblock this profile. Physical unblock setting is on for this profile"
69 | )
70 | return
71 | }
72 |
73 | session.endSession()
74 | self.appBlocker.deactivateRestrictions()
75 |
76 | self.onSessionCreation?(.ended(session.blockedProfile))
77 | case .failure(let error):
78 | self.onErrorMessage?(error.localizedDescription)
79 | }
80 | }
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/Foqos/Components/Debug/SessionDebugCard.swift:
--------------------------------------------------------------------------------
1 | import SwiftData
2 | import SwiftUI
3 |
4 | struct SessionDebugCard: View {
5 | let session: BlockedProfileSession
6 |
7 | var body: some View {
8 | VStack(alignment: .leading, spacing: 8) {
9 | // Basic Info
10 | Group {
11 | DebugRow(label: "Session ID", value: session.id)
12 | DebugRow(label: "Tag", value: session.tag)
13 | DebugRow(label: "Start Time", value: DateFormatters.formatDate(session.startTime))
14 | DebugRow(
15 | label: "End Time",
16 | value: session.endTime.map { DateFormatters.formatDate($0) } ?? "nil (active)"
17 | )
18 | DebugRow(label: "Force Started", value: "\(session.forceStarted)")
19 | }
20 |
21 | Divider()
22 |
23 | // Status Flags
24 | Group {
25 | DebugRow(label: "Is Active", value: "\(session.isActive)")
26 | DebugRow(label: "Is Break Available", value: "\(session.isBreakAvailable)")
27 | DebugRow(label: "Is Break Active", value: "\(session.isBreakActive)")
28 | }
29 |
30 | Divider()
31 |
32 | // Break Times
33 | Group {
34 | DebugRow(
35 | label: "Break Start Time",
36 | value: session.breakStartTime.map { DateFormatters.formatDate($0) } ?? "nil"
37 | )
38 | DebugRow(
39 | label: "Break End Time",
40 | value: session.breakEndTime.map { DateFormatters.formatDate($0) } ?? "nil"
41 | )
42 | }
43 |
44 | Divider()
45 |
46 | // Duration
47 | DebugRow(label: "Duration", value: DateFormatters.formatDuration(session.duration))
48 | }
49 | }
50 | }
51 |
52 | #Preview {
53 | let profile = BlockedProfiles(name: "Work Focus")
54 | let session = BlockedProfileSession(
55 | tag: "manual-start",
56 | blockedProfile: profile,
57 | forceStarted: false
58 | )
59 |
60 | return SessionDebugCard(session: session)
61 | .padding()
62 | .modelContainer(for: [BlockedProfiles.self, BlockedProfileSession.self])
63 | }
64 |
65 | #Preview("Active Session with Break") {
66 | let profile = BlockedProfiles(
67 | name: "Deep Work",
68 | enableBreaks: true
69 | )
70 | let session = BlockedProfileSession(
71 | tag: "nfc-scan",
72 | blockedProfile: profile,
73 | forceStarted: false
74 | )
75 | session.startBreak()
76 |
77 | return SessionDebugCard(session: session)
78 | .padding()
79 | .modelContainer(for: [BlockedProfiles.self, BlockedProfileSession.self])
80 | }
81 |
82 | #Preview("Completed Session") {
83 | let profile = BlockedProfiles(name: "Study Time")
84 | let session = BlockedProfileSession(
85 | tag: "scheduled",
86 | blockedProfile: profile,
87 | forceStarted: true
88 | )
89 | session.endSession()
90 |
91 | return SessionDebugCard(session: session)
92 | .padding()
93 | .modelContainer(for: [BlockedProfiles.self, BlockedProfileSession.self])
94 | }
95 |
--------------------------------------------------------------------------------
/Foqos/Components/Dashboard/Welcome.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct Welcome: View {
4 | @EnvironmentObject var themeManager: ThemeManager
5 | let onTap: () -> Void
6 |
7 | var body: some View {
8 | Button(action: onTap) {
9 | VStack(alignment: .leading, spacing: 12) {
10 | // Top row with category and icon
11 | HStack {
12 | Text("Physically block distracting apps ")
13 | .font(.subheadline)
14 | .fontWeight(.medium)
15 | .foregroundColor(.primary)
16 |
17 | Spacer()
18 |
19 | Image(systemName: "hourglass")
20 | .font(.body)
21 | .foregroundColor(.white)
22 | .padding(8)
23 | .background(
24 | Circle()
25 | .fill(themeManager.themeColor.opacity(0.8))
26 | )
27 | }
28 |
29 | Spacer()
30 | .frame(height: 10)
31 |
32 | // Title and subtitle
33 | Text("Welcome to Foqos")
34 | .font(.title)
35 | .fontWeight(.bold)
36 | .foregroundColor(.primary)
37 |
38 | Text(
39 | "Tap here to get started on your first profile. You can use NFC Tags, QR codes or even Barcode codes."
40 | )
41 | .font(.subheadline)
42 | .foregroundColor(.secondary)
43 | .lineLimit(3)
44 | }
45 | .padding(20)
46 | .frame(maxWidth: .infinity, minHeight: 150)
47 | .background(
48 | RoundedRectangle(cornerRadius: 24)
49 | .fill(Color(UIColor.systemBackground))
50 | .overlay(
51 | GeometryReader { geometry in
52 | ZStack {
53 | // Theme color circle blob
54 | Circle()
55 | .fill(themeManager.themeColor.opacity(0.5))
56 | .frame(width: geometry.size.width * 0.5)
57 | .position(
58 | x: geometry.size.width * 0.9,
59 | y: geometry.size.height / 2
60 | )
61 | .blur(radius: 15)
62 | }
63 | }
64 | )
65 | .overlay(
66 | RoundedRectangle(cornerRadius: 24)
67 | .fill(.ultraThinMaterial.opacity(0.7))
68 | )
69 | .clipShape(RoundedRectangle(cornerRadius: 24))
70 | )
71 | }
72 | .buttonStyle(ScaleButtonStyle())
73 | }
74 | }
75 |
76 | struct ScaleButtonStyle: ButtonStyle {
77 | func makeBody(configuration: Configuration) -> some View {
78 | configuration.label
79 | .scaleEffect(configuration.isPressed ? 0.98 : 1.0)
80 | .animation(.spring(response: 0.3), value: configuration.isPressed)
81 | }
82 | }
83 |
84 | #Preview {
85 | ZStack {
86 | Color.gray.opacity(0.1).ignoresSafeArea()
87 |
88 | Welcome(onTap: {
89 | print("Card tapped")
90 | })
91 | .padding(.horizontal)
92 | .environmentObject(ThemeManager.shared)
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/Foqos/Utils/ThemeManager.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | class ThemeManager: ObservableObject {
4 | static let shared = ThemeManager()
5 |
6 | // Single source of truth for all theme colors
7 | static let availableColors: [(name: String, color: Color)] = [
8 | ("Grimace Purple", Color(hex: "#894fa3")),
9 | ("Ocean Blue", Color(hex: "#007aff")),
10 | ("Mint Fresh", Color(hex: "#00c6bf")),
11 | ("Lime Zest", Color(hex: "#7fd800")),
12 | ("Sunset Coral", Color(hex: "#ff5966")),
13 | ("Hot Pink", Color(hex: "#ff2da5")),
14 | ("Tangerine", Color(hex: "#ff9300")),
15 | ("Lavender Dream", Color(hex: "#ba8eff")),
16 | ("San Diego Merlot", Color(hex: "#7a1e3a")),
17 | ("Forest Green", Color(hex: "#0b6e4f")),
18 | ("Miami Vice", Color(hex: "#ff6ec7")),
19 | ("Electric Lemonade", Color(hex: "#ccff00")),
20 | ("Neon Grape", Color(hex: "#b026ff")),
21 | ("Slate Stone", Color(hex: "#708090")),
22 | ("Warm Sandstone", Color(hex: "#c4a77d")),
23 | ]
24 |
25 | private static let defaultColorName = "Grimace Purple"
26 |
27 | @AppStorage(
28 | "foqosThemeColorName", store: UserDefaults(suiteName: "group.dev.ambitionsoftware.foqos"))
29 | private var themeColorName: String = defaultColorName
30 |
31 | var selectedColorName: String {
32 | get { themeColorName }
33 | set {
34 | themeColorName = newValue
35 | objectWillChange.send()
36 | }
37 | }
38 |
39 | var themeColor: Color {
40 | Self.availableColors.first(where: { $0.name == themeColorName })?.color
41 | ?? Self.availableColors.first!.color
42 | }
43 |
44 | func setTheme(named name: String) {
45 | selectedColorName = name
46 | }
47 | }
48 |
49 | extension Color {
50 | init(hex: String) {
51 | let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
52 | var int: UInt64 = 0
53 | Scanner(string: hex).scanHexInt64(&int)
54 | let a: UInt64
55 | let r: UInt64
56 | let g: UInt64
57 | let b: UInt64
58 | switch hex.count {
59 | case 3: // RGB (12-bit)
60 | (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
61 | case 6: // RGB (24-bit)
62 | (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
63 | case 8: // ARGB (32-bit)
64 | (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
65 | default:
66 | (a, r, g, b) = (1, 1, 1, 0)
67 | }
68 |
69 | self.init(
70 | .sRGB,
71 | red: Double(r) / 255,
72 | green: Double(g) / 255,
73 | blue: Double(b) / 255,
74 | opacity: Double(a) / 255
75 | )
76 | }
77 |
78 | func toHex() -> String? {
79 | let uiColor = UIColor(self)
80 | var r: CGFloat = 0
81 | var g: CGFloat = 0
82 | var b: CGFloat = 0
83 | var a: CGFloat = 0
84 |
85 | guard uiColor.getRed(&r, green: &g, blue: &b, alpha: &a) else {
86 | return nil
87 | }
88 |
89 | let rgb: Int = (Int)(r * 255) << 16 | (Int)(g * 255) << 8 | (Int)(b * 255) << 0
90 |
91 | return String(format: "#%06x", rgb)
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/Foqos/Components/BlockedProfileView/BlockedProfilePhysicalUnblockSelector.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct BlockedProfilePhysicalUnblockSelector: View {
4 | let nfcTagId: String?
5 | let qrCodeId: String?
6 | var disabled: Bool = false
7 | var disabledText: String?
8 |
9 | let onSetNFC: () -> Void
10 | let onSetQRCode: () -> Void
11 | let onUnsetNFC: () -> Void
12 | let onUnsetQRCode: () -> Void
13 |
14 | var body: some View {
15 | VStack(alignment: .leading, spacing: 12) {
16 | HStack(spacing: 12) {
17 | // NFC Tag Column
18 | PhysicalUnblockColumn(
19 | title: "NFC Tag",
20 | description: "Set a specific NFC tag that can only unblock this profile when active",
21 | systemImage: "wave.3.right.circle.fill",
22 | id: nfcTagId,
23 | disabled: disabled,
24 | onSet: onSetNFC,
25 | onUnset: onUnsetNFC
26 | )
27 |
28 | // QR Code Column
29 | PhysicalUnblockColumn(
30 | title: "QR/Barcode Code",
31 | description:
32 | "Set a specific QR/Barcode code that can only unblock this profile when active",
33 | systemImage: "qrcode.viewfinder",
34 | id: qrCodeId,
35 | disabled: disabled,
36 | onSet: onSetQRCode,
37 | onUnset: onUnsetQRCode
38 | )
39 | }
40 |
41 | if let disabledText = disabledText, disabled {
42 | Text(disabledText)
43 | .foregroundStyle(.red)
44 | .padding(.top, 4)
45 | .font(.caption)
46 | }
47 | }.padding(0)
48 | }
49 | }
50 |
51 | #Preview {
52 | NavigationStack {
53 | Form {
54 | Section {
55 | // Example with no IDs set
56 | BlockedProfilePhysicalUnblockSelector(
57 | nfcTagId: nil,
58 | qrCodeId: nil,
59 | disabled: false,
60 | onSetNFC: { print("Set NFC") },
61 | onSetQRCode: { print("Set QR Code") },
62 | onUnsetNFC: { print("Unset NFC") },
63 | onUnsetQRCode: { print("Unset QR Code") }
64 | )
65 | }
66 |
67 | Section {
68 | // Example with IDs set
69 | BlockedProfilePhysicalUnblockSelector(
70 | nfcTagId: "nfc_12345678901234567890",
71 | qrCodeId: "qr_abcdefghijklmnopqrstuvwxyz",
72 | disabled: false,
73 | onSetNFC: { print("Set NFC") },
74 | onSetQRCode: { print("Set QR Code") },
75 | onUnsetNFC: { print("Unset NFC") },
76 | onUnsetQRCode: { print("Unset QR Code") }
77 | )
78 | }
79 |
80 | Section {
81 | // Example disabled
82 | BlockedProfilePhysicalUnblockSelector(
83 | nfcTagId: "nfc_12345678901234567890",
84 | qrCodeId: nil,
85 | disabled: true,
86 | disabledText: "Physical unblock options are locked",
87 | onSetNFC: { print("Set NFC") },
88 | onSetQRCode: { print("Set QR Code") },
89 | onUnsetNFC: { print("Unset NFC") },
90 | onUnsetQRCode: { print("Unset QR Code") }
91 | )
92 | }
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/Foqos/Models/Strategies/QRCodeBlockingStrategy.swift:
--------------------------------------------------------------------------------
1 | import CodeScanner
2 | import SwiftData
3 | import SwiftUI
4 |
5 | class QRCodeBlockingStrategy: BlockingStrategy {
6 | static var id: String = "QRCodeBlockingStrategy"
7 |
8 | var name: String = "QR Codes"
9 | var description: String =
10 | "Block and unblock profiles by scanning the same QR/Barcode code"
11 | var iconType: String = "qrcode.viewfinder"
12 | var color: Color = .pink
13 |
14 | var onSessionCreation: ((SessionStatus) -> Void)?
15 | var onErrorMessage: ((String) -> Void)?
16 |
17 | private let appBlocker: AppBlockerUtil = AppBlockerUtil()
18 |
19 | func getIdentifier() -> String {
20 | return QRCodeBlockingStrategy.id
21 | }
22 |
23 | func startBlocking(
24 | context: ModelContext,
25 | profile: BlockedProfiles,
26 | forceStart: Bool?
27 | ) -> (any View)? {
28 | return LabeledCodeScannerView(
29 | heading: "Scan to start",
30 | subtitle: "Point your camera at a QR code to activate a profile."
31 | ) { result in
32 | switch result {
33 | case .success(let result):
34 | self.appBlocker.activateRestrictions(for: BlockedProfiles.getSnapshot(for: profile))
35 |
36 | let tag = result.string
37 | let activeSession =
38 | BlockedProfileSession
39 | .createSession(
40 | in: context,
41 | withTag: tag,
42 | withProfile: profile,
43 | forceStart: forceStart ?? false
44 | )
45 | self.onSessionCreation?(.started(activeSession))
46 | case .failure(let error):
47 | self.onErrorMessage?(error.localizedDescription)
48 | }
49 | }
50 | }
51 |
52 | func stopBlocking(
53 | context: ModelContext,
54 | session: BlockedProfileSession
55 | ) -> (any View)? {
56 | return LabeledCodeScannerView(
57 | heading: "Scan to stop",
58 | subtitle: "Point your camera at a QR code to deactivate a profile."
59 | ) { result in
60 | switch result {
61 | case .success(let result):
62 | let tag = result.string
63 |
64 | // Validate the scanned QR code for unblocking
65 | if let physicalUnblockQRCodeId = session.blockedProfile.physicalUnblockQRCodeId {
66 | // Physical unblock QR code is set - only this specific code can unblock
67 | if physicalUnblockQRCodeId != tag {
68 | self.onErrorMessage?(
69 | "This QR code is not allowed to unblock this profile. Physical unblock setting is on for this profile"
70 | )
71 | return
72 | }
73 | } else if !session.forceStarted && session.tag != tag {
74 | // No physical unblock code - must use original session code (unless force started)
75 | self.onErrorMessage?(
76 | "You must scan the original QR code to stop focus"
77 | )
78 | return
79 | }
80 |
81 | session.endSession()
82 | self.appBlocker.deactivateRestrictions()
83 |
84 | self.onSessionCreation?(.ended(session.blockedProfile))
85 | case .failure(let error):
86 | self.onErrorMessage?(error.localizedDescription)
87 | }
88 | }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/Foqos/Views/SupportView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | let THREADS_URL = "https://www.threads.com/@softwarecuddler"
4 | let TWITTER_URL = "https://x.com/softwarecuddler"
5 | let DONATE_URL = "https://buymeacoffee.com/softwarecuddler" // You can replace this with your actual donation URL
6 |
7 | struct SupportView: View {
8 | @EnvironmentObject var donationManager: TipManager
9 | @EnvironmentObject var themeManager: ThemeManager
10 |
11 | @State private var stampScale: CGFloat = 0.1
12 | @State private var stampRotation: Double = 0
13 | @State private var stampOpacity: Double = 0.0
14 |
15 | var body: some View {
16 | // Thank you stamp image and header
17 | VStack(alignment: .center, spacing: 30) {
18 | Spacer()
19 |
20 | Image("ThankYouStamp")
21 | .resizable()
22 | .aspectRatio(contentMode: .fit)
23 | .frame(width: 300, height: 300)
24 | .scaleEffect(stampScale)
25 | .rotationEffect(.degrees(stampRotation))
26 | .opacity(stampOpacity)
27 | .onAppear {
28 | withAnimation(.spring(response: 0.4, dampingFraction: 0.6, blendDuration: 0)) {
29 | stampScale = 1
30 | stampRotation = 8
31 | stampOpacity = 1
32 | }
33 | }
34 | .padding(.bottom, 20)
35 |
36 | Text(
37 | "Thank you for your support! I created Foqos because I believe everyone deserves tools to live with more focus and intention. Your support, whether through reviews, shares, or donations helps keep this dream alive and accessible to everyone who needs it"
38 | )
39 | .font(.body)
40 | .multilineTextAlignment(.center)
41 | .foregroundColor(.secondary)
42 | .fadeInSlide(delay: 0.3)
43 |
44 | Text(
45 | "Questions? Reach out to me."
46 | )
47 | .font(.body)
48 | .multilineTextAlignment(.center)
49 | .foregroundColor(.secondary)
50 | .fadeInSlide(delay: 0.4)
51 |
52 | HStack(alignment: .center, spacing: 20) {
53 | Link(destination: URL(string: THREADS_URL)!) {
54 | Image("Threads")
55 | .resizable()
56 | .aspectRatio(contentMode: .fit)
57 | .frame(width: 30, height: 30)
58 | }
59 |
60 | Link(destination: URL(string: TWITTER_URL)!) {
61 | Image("Twitter")
62 | .resizable()
63 | .aspectRatio(contentMode: .fit)
64 | .frame(width: 30, height: 30)
65 | }
66 | }
67 | .fadeInSlide(delay: 0.5)
68 |
69 | Spacer()
70 |
71 | ActionButton(
72 | title: donationManager.hasPurchasedTip ? "Thank you for the donation" : "Donate",
73 | backgroundColor: donationManager.hasPurchasedTip ? .gray : themeManager.themeColor,
74 | iconName: "heart.fill",
75 | iconColor: donationManager.hasPurchasedTip ? .red : nil,
76 | isLoading: donationManager.loadingTip,
77 | action: {
78 | if !donationManager.hasPurchasedTip {
79 | donationManager.tip()
80 | }
81 | }
82 | )
83 | .fadeInSlide(delay: 0.6)
84 | }
85 | .padding(.horizontal, 20)
86 | }
87 | }
88 |
89 | #Preview {
90 | NavigationView {
91 | SupportView()
92 | .environmentObject(TipManager())
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/Foqos/Components/Dashboard/VersionFooter.swift:
--------------------------------------------------------------------------------
1 | import FamilyControls
2 | import SwiftUI
3 |
4 | let AMZN_STORE_LINK = "https://amzn.to/4fbMuTM"
5 |
6 | struct VersionFooter: View {
7 | @EnvironmentObject var themeManager: ThemeManager
8 |
9 | let profileIsActive: Bool
10 | let tapProfileDebugHandler: () -> Void
11 |
12 | let authorizationStatus: AuthorizationStatus
13 | let onAuthorizationHandler: () -> Void
14 |
15 | // Get the current app version from the bundle
16 | private var appVersion: String {
17 | Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
18 | ?? "1.0"
19 | }
20 |
21 | private var isAuthorized: Bool {
22 | authorizationStatus == .approved
23 | }
24 |
25 | var body: some View {
26 | VStack(spacing: 10) {
27 | HStack(alignment: .center, spacing: 4) {
28 | if isAuthorized {
29 | HStack(spacing: 8) {
30 | Circle()
31 | .fill(.green)
32 | .frame(width: 8, height: 8)
33 | Text("All systems functional")
34 | .font(.footnote)
35 | .foregroundColor(.secondary)
36 | }
37 | } else {
38 | Button(action: onAuthorizationHandler) {
39 | HStack(spacing: 6) {
40 | Circle()
41 | .fill(.red)
42 | .frame(width: 8, height: 8)
43 | Text("Authorization required. Tap to authorize.")
44 | .font(.footnote)
45 | }
46 | }
47 | .foregroundColor(.red)
48 | }
49 |
50 | Text("(v\(appVersion))")
51 | .font(.footnote)
52 | .foregroundColor(.secondary)
53 | }
54 |
55 | Text("Made with ❤️ in Calgary, AB 🇨🇦")
56 | .font(.footnote)
57 | .foregroundColor(.secondary)
58 |
59 | if profileIsActive {
60 | Button(action: tapProfileDebugHandler) {
61 | Text("Debug mode")
62 | .font(.footnote)
63 | .foregroundColor(.blue)
64 | }
65 | } else {
66 | Link(
67 | "Buy NFC Tags",
68 | destination: URL(string: AMZN_STORE_LINK)!
69 | )
70 | .font(.footnote)
71 | .tint(themeManager.themeColor)
72 | }
73 | }
74 | .padding(.bottom, 8)
75 | }
76 | }
77 |
78 | // Preview provider for SwiftUI canvas
79 | struct VersionFooter_Previews: PreviewProvider {
80 | static var previews: some View {
81 | VStack(spacing: 20) {
82 | VersionFooter(
83 | profileIsActive: false,
84 | tapProfileDebugHandler: {},
85 | authorizationStatus: .approved,
86 | onAuthorizationHandler: {}
87 | )
88 | .previewDisplayName("Authorized")
89 |
90 | VersionFooter(
91 | profileIsActive: false,
92 | tapProfileDebugHandler: {},
93 | authorizationStatus: .denied,
94 | onAuthorizationHandler: {}
95 | )
96 | .previewDisplayName("Not Authorized")
97 |
98 | VersionFooter(
99 | profileIsActive: true,
100 | tapProfileDebugHandler: {},
101 | authorizationStatus: .approved,
102 | onAuthorizationHandler: {}
103 | )
104 | .previewDisplayName("Profile is active")
105 | }
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/Foqos/Components/Common/GlassButton.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct GlassButton: View {
4 | let title: String
5 | let icon: String
6 | var fullWidth: Bool = true
7 | var equalWidth: Bool = false
8 | var longPressEnabled: Bool = false
9 | var longPressDuration: Double = 0.8
10 | var color: Color? = nil
11 | let action: () -> Void
12 |
13 | var body: some View {
14 | if longPressEnabled {
15 | longPressButton
16 | } else {
17 | standardButton
18 | }
19 | }
20 |
21 | private var standardButton: some View {
22 | Button(action: action) {
23 | buttonContent
24 | }
25 | .buttonStyle(PressableButtonStyle())
26 | .frame(minWidth: 0, maxWidth: equalWidth ? .infinity : nil)
27 | }
28 |
29 | @State private var isPressed = false
30 |
31 | private var longPressButton: some View {
32 | buttonContent
33 | .contentShape(Rectangle())
34 | .frame(minWidth: 0, maxWidth: equalWidth ? .infinity : nil)
35 | .scaleEffect(isPressed ? 0.96 : 1.0)
36 | .animation(.spring(response: 0.3), value: isPressed)
37 | .onLongPressGesture(
38 | minimumDuration: longPressDuration,
39 | pressing: { pressing in
40 | isPressed = pressing
41 | },
42 | perform: {
43 | UIImpactFeedbackGenerator(style: .medium).impactOccurred()
44 | action()
45 | isPressed = false
46 | }
47 | )
48 |
49 | }
50 |
51 | private var buttonContent: some View {
52 | HStack(spacing: 6) {
53 | Image(systemName: icon)
54 | .font(.system(size: 16, weight: .medium))
55 | Text(title)
56 | .fontWeight(.semibold)
57 | .font(.subheadline)
58 | }
59 | .frame(
60 | minWidth: 0,
61 | maxWidth: fullWidth ? .infinity : (equalWidth ? .infinity : nil)
62 | )
63 | .padding(.vertical, 12)
64 | .padding(.horizontal, fullWidth ? nil : 24)
65 | .background(
66 | RoundedRectangle(cornerRadius: 16)
67 | .fill(.thinMaterial)
68 | .overlay(
69 | RoundedRectangle(cornerRadius: 16)
70 | .stroke((color ?? Color.primary).opacity(0.2), lineWidth: 1)
71 | )
72 | )
73 | .foregroundColor(color ?? .primary)
74 | }
75 | }
76 |
77 | private struct PressableButtonStyle: ButtonStyle {
78 | func makeBody(configuration: Configuration) -> some View {
79 | configuration.label
80 | .contentShape(Rectangle())
81 | .scaleEffect(configuration.isPressed ? 0.96 : 1.0)
82 | .animation(.spring(response: 0.3), value: configuration.isPressed)
83 | }
84 | }
85 |
86 | #Preview {
87 | VStack(spacing: 20) {
88 | GlassButton(
89 | title: "Regular Button",
90 | icon: "play.fill"
91 | ) {
92 | print("Regular button tapped")
93 | }
94 |
95 | GlassButton(
96 | title: "Blue Button",
97 | icon: "star.fill",
98 | color: .blue
99 | ) {
100 | print("Blue button tapped")
101 | }
102 |
103 | GlassButton(
104 | title: "Hold to Start",
105 | icon: "play.fill",
106 | longPressEnabled: true,
107 | color: .green
108 | ) {
109 | print("Long press completed")
110 | }
111 | }
112 | .padding()
113 | .background(Color(.systemGroupedBackground))
114 | }
115 |
--------------------------------------------------------------------------------
/Foqos/Components/Debug/DeviceActivitiesDebugCard.swift:
--------------------------------------------------------------------------------
1 | import DeviceActivity
2 | import SwiftUI
3 |
4 | struct DeviceActivitiesDebugCard: View {
5 | let activities: [DeviceActivityName]
6 | let profileId: UUID?
7 |
8 | var body: some View {
9 | VStack(alignment: .leading, spacing: 8) {
10 | if activities.isEmpty {
11 | Text("No device activities scheduled")
12 | .font(.caption)
13 | .foregroundColor(.secondary)
14 | } else {
15 | DebugRow(label: "Total Activities", value: "\(activities.count)")
16 |
17 | Divider()
18 |
19 | ForEach(Array(activities.enumerated()), id: \.element.rawValue) { index, activity in
20 | VStack(alignment: .leading, spacing: 4) {
21 | Text("Activity \(index + 1)")
22 | .font(.caption)
23 | .foregroundColor(.secondary)
24 | .bold()
25 |
26 | DebugRow(label: "Name", value: activity.rawValue)
27 | DebugRow(label: "Type", value: activityType(for: activity))
28 |
29 | if let profileId = profileId {
30 | DebugRow(
31 | label: "Matches Profile",
32 | value: "\(isActivityForProfile(activity, profileId: profileId))"
33 | )
34 | }
35 | }
36 |
37 | if index < activities.count - 1 {
38 | Divider()
39 | }
40 | }
41 | }
42 | }
43 | }
44 |
45 | private func activityType(for activity: DeviceActivityName) -> String {
46 | let rawValue = activity.rawValue
47 |
48 | if rawValue.hasPrefix(BreakTimerActivity.id) {
49 | return "Break Timer"
50 | } else if rawValue.hasPrefix(ScheduleTimerActivity.id) {
51 | return "Schedule Timer"
52 | } else if rawValue.hasPrefix(StrategyTimerActivity.id) {
53 | return "Strategy Timer"
54 | } else {
55 | // Check if it's a UUID (legacy schedule format)
56 | if UUID(uuidString: rawValue) != nil {
57 | return "Schedule Timer (Legacy)"
58 | }
59 | return "Unknown"
60 | }
61 | }
62 |
63 | private func isActivityForProfile(_ activity: DeviceActivityName, profileId: UUID) -> Bool {
64 | let rawValue = activity.rawValue
65 | let profileIdString = profileId.uuidString
66 |
67 | // Check if it's a break timer activity for this profile
68 | if rawValue.hasPrefix(BreakTimerActivity.id) {
69 | return rawValue.hasSuffix(profileIdString)
70 | }
71 |
72 | // Check if it's a schedule timer activity for this profile
73 | if rawValue.hasPrefix(ScheduleTimerActivity.id) {
74 | return rawValue.hasSuffix(profileIdString)
75 | }
76 |
77 | // Check if it's a legacy schedule format (just the UUID)
78 | return rawValue == profileIdString
79 | }
80 | }
81 |
82 | #Preview {
83 | DeviceActivitiesDebugCard(
84 | activities: [
85 | DeviceActivityName(rawValue: "550e8400-e29b-41d4-a716-446655440000"),
86 | DeviceActivityName(
87 | rawValue: "BreakScheduleActivity:550e8400-e29b-41d4-a716-446655440000"),
88 | DeviceActivityName(
89 | rawValue: "ScheduleTimerActivity:550e8400-e29b-41d4-a716-446655440000"),
90 | ],
91 | profileId: UUID(uuidString: "550e8400-e29b-41d4-a716-446655440000")
92 | )
93 | .padding()
94 | }
95 |
96 | #Preview("Empty") {
97 | DeviceActivitiesDebugCard(
98 | activities: [],
99 | profileId: nil
100 | )
101 | .padding()
102 | }
103 |
--------------------------------------------------------------------------------
/Foqos/Components/Strategy/QRCodeView.swift:
--------------------------------------------------------------------------------
1 | import CoreImage
2 | import CoreImage.CIFilterBuiltins
3 | import SwiftUI
4 | import UIKit
5 |
6 | struct QRCodeView: View {
7 | @EnvironmentObject var themeManager: ThemeManager
8 | @Environment(\.dismiss) private var dismiss
9 |
10 | let url: String
11 | let profileName: String
12 | @State private var qrCodeImage: UIImage? = nil
13 |
14 | var body: some View {
15 | NavigationView {
16 | VStack(spacing: 30) {
17 | // Profile name heading
18 | Text(profileName)
19 | .font(.title)
20 | .fontWeight(.bold)
21 | .multilineTextAlignment(.center)
22 | .padding(.top)
23 |
24 | // QR Code
25 | if let qrCodeImage {
26 | Image(uiImage: qrCodeImage)
27 | .interpolation(.none)
28 | .resizable()
29 | .scaledToFit()
30 | .frame(width: 250, height: 250)
31 | } else {
32 | ProgressView()
33 | .frame(width: 250, height: 250)
34 | }
35 |
36 | // Description text
37 | Text("Scan this code without the app running to start/stop this profile")
38 | .font(.body)
39 | .multilineTextAlignment(.center)
40 | .foregroundColor(.secondary)
41 | .padding(.horizontal)
42 | .fixedSize(horizontal: false, vertical: true)
43 |
44 | // Share button using ShareLink
45 | if let qrCodeImage {
46 | ShareLink(
47 | item: Image(uiImage: qrCodeImage),
48 | preview: SharePreview(
49 | profileName,
50 | image: Image(uiImage: qrCodeImage)
51 | )
52 | ) {
53 | HStack {
54 | Image(systemName: "square.and.arrow.up")
55 | Text("Share QR code")
56 | }
57 | .frame(maxWidth: .infinity)
58 | .padding()
59 | .background(themeManager.themeColor)
60 | .foregroundColor(.white)
61 | .clipShape(RoundedRectangle(cornerRadius: 10))
62 | }
63 | .padding(.horizontal)
64 | }
65 | }
66 | .padding()
67 | .navigationBarTitleDisplayMode(.inline)
68 | .toolbar {
69 | ToolbarItem(placement: .navigationBarLeading) {
70 | Button {
71 | dismiss()
72 | } label: {
73 | Image(systemName: "xmark")
74 | .foregroundColor(.primary)
75 | }
76 | }
77 | }
78 | .onAppear {
79 | generateQRCode(from: url)
80 | }
81 | }
82 | }
83 |
84 | private func generateQRCode(from string: String) {
85 | // Create the QR code filter
86 | let context = CIContext()
87 | let filter = CIFilter.qrCodeGenerator()
88 |
89 | // Set the input message
90 | let data = Data(string.utf8)
91 | filter.setValue(data, forKey: "inputMessage")
92 | filter.setValue("M", forKey: "inputCorrectionLevel")
93 |
94 | // Get the output image
95 | if let outputImage = filter.outputImage {
96 | // Scale the image
97 | let transform = CGAffineTransform(scaleX: 10, y: 10)
98 | let scaledImage = outputImage.transformed(by: transform)
99 |
100 | // Convert to UIImage
101 | if let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) {
102 | self.qrCodeImage = UIImage(cgImage: cgImage)
103 | }
104 | }
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/Foqos/Components/Common/RoundedButton.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import UIKit
3 |
4 | struct RoundedButton: View {
5 | let text: String
6 | let action: () -> Void
7 | let backgroundColor: Color
8 | let textColor: Color
9 | let font: Font
10 | let fontWeight: Font.Weight
11 | let iconName: String?
12 |
13 | init(
14 | _ text: String,
15 | action: @escaping () -> Void,
16 | backgroundColor: Color = Color.secondary.opacity(0.2),
17 | textColor: Color = .gray,
18 | font: Font = .subheadline,
19 | fontWeight: Font.Weight = .medium,
20 | iconName: String? = nil
21 | ) {
22 | self.text = text
23 | self.action = action
24 | self.backgroundColor = backgroundColor
25 | self.textColor = textColor
26 | self.font = font
27 | self.fontWeight = fontWeight
28 | self.iconName = iconName
29 | }
30 |
31 | var body: some View {
32 | Button(action: {
33 | let impactFeedback = UIImpactFeedbackGenerator(style: .light)
34 | impactFeedback.impactOccurred()
35 |
36 | action()
37 | }) {
38 | HStack(spacing: 6) {
39 | if let iconName = iconName {
40 | Image(systemName: iconName)
41 | .font(font)
42 | .fontWeight(fontWeight)
43 | }
44 |
45 | if !text.isEmpty {
46 | Text(text)
47 | .font(font)
48 | .fontWeight(fontWeight)
49 | }
50 | }
51 | .foregroundColor(textColor)
52 | .padding(.horizontal, 12)
53 | .padding(.vertical, 8)
54 | .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
55 | .glassButtonBackground(cornerRadius: 16)
56 | .overlay(
57 | RoundedRectangle(cornerRadius: 16, style: .continuous)
58 | .strokeBorder(.white.opacity(0.15))
59 | )
60 | }
61 | .buttonStyle(PlainButtonStyle())
62 | }
63 | }
64 |
65 | extension View {
66 | @ViewBuilder
67 | fileprivate func glassButtonBackground(cornerRadius: CGFloat) -> some View {
68 | if #available(iOS 26.0, *) {
69 | self.modifier(GlassBackgroundModifier(cornerRadius: cornerRadius))
70 | } else {
71 | self
72 | .background(
73 | .ultraThinMaterial,
74 | in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
75 | )
76 | }
77 | }
78 | }
79 |
80 | @available(iOS 26.0, *)
81 | private struct GlassBackgroundModifier: ViewModifier {
82 | let cornerRadius: CGFloat
83 |
84 | func body(content: Content) -> some View {
85 | content
86 | .glassEffect(in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
87 | }
88 | }
89 |
90 | // Preview
91 | #Preview {
92 | VStack(spacing: 16) {
93 | RoundedButton("See All") {
94 | print("See All tapped")
95 | }
96 |
97 | RoundedButton(
98 | "View Report",
99 | action: { print("View Report tapped") },
100 | iconName: "chart.bar")
101 |
102 | RoundedButton(
103 | "Custom Style",
104 | action: { print("Custom tapped") },
105 | backgroundColor: .blue,
106 | textColor: .white,
107 | iconName: "star.fill")
108 |
109 | RoundedButton(
110 | "Large Button",
111 | action: { print("Large tapped") },
112 | backgroundColor: .green.opacity(0.2),
113 | textColor: .green,
114 | font: .title3,
115 | fontWeight: .semibold,
116 | iconName: "checkmark.circle")
117 |
118 | RoundedButton(
119 | "Settings",
120 | action: { print("Settings tapped") },
121 | iconName: "gear")
122 | }
123 | .padding(20)
124 | }
125 |
--------------------------------------------------------------------------------
/Foqos/Components/Intro/IntroStepper.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct IntroStepper: View {
4 | let currentStep: Int
5 | let totalSteps: Int
6 | let onNext: () -> Void
7 | let onBack: () -> Void
8 | let nextButtonTitle: String
9 | let showBackButton: Bool
10 |
11 | @State private var buttonsVisible: Bool = false
12 |
13 | init(
14 | currentStep: Int,
15 | totalSteps: Int,
16 | onNext: @escaping () -> Void,
17 | onBack: @escaping () -> Void,
18 | nextButtonTitle: String = "Next",
19 | showBackButton: Bool = true
20 | ) {
21 | self.currentStep = currentStep
22 | self.totalSteps = totalSteps
23 | self.onNext = onNext
24 | self.onBack = onBack
25 | self.nextButtonTitle = nextButtonTitle
26 | self.showBackButton = showBackButton
27 | }
28 |
29 | var body: some View {
30 | VStack(spacing: 16) {
31 | // Buttons
32 | HStack(spacing: 12) {
33 | // Back button
34 | if showBackButton && currentStep > 0 {
35 | Button(action: onBack) {
36 | HStack {
37 | Image(systemName: "chevron.left")
38 | .font(.system(size: 14, weight: .semibold))
39 | Text("Back")
40 | .font(.system(size: 16, weight: .semibold))
41 | }
42 | .foregroundColor(.primary)
43 | .frame(maxWidth: .infinity)
44 | .frame(height: 50)
45 | .background(
46 | RoundedRectangle(cornerRadius: 12)
47 | .fill(Color.gray.opacity(0.1))
48 | )
49 | }
50 | .transition(.scale.combined(with: .opacity))
51 | }
52 |
53 | // Next/Continue button
54 | Button(action: onNext) {
55 | HStack {
56 | Text(nextButtonTitle)
57 | .font(.system(size: 16, weight: .semibold))
58 | Image(systemName: "chevron.right")
59 | .font(.system(size: 14, weight: .semibold))
60 | }
61 | .foregroundColor(.white)
62 | .frame(maxWidth: .infinity)
63 | .frame(height: 50)
64 | .background(
65 | RoundedRectangle(cornerRadius: 12)
66 | .fill(
67 | LinearGradient(
68 | gradient: Gradient(colors: [Color.purple, Color.purple.opacity(0.8)]),
69 | startPoint: .topLeading,
70 | endPoint: .bottomTrailing
71 | )
72 | )
73 | )
74 | }
75 | }
76 | .opacity(buttonsVisible ? 1 : 0)
77 | .offset(y: buttonsVisible ? 0 : 20)
78 | }
79 | .padding(.horizontal, 20)
80 | .padding(.top, 30)
81 | .padding(.bottom, 20)
82 | .onAppear {
83 | withAnimation(.easeOut(duration: 0.5).delay(0.2)) {
84 | buttonsVisible = true
85 | }
86 | }
87 |
88 | // Progress dots
89 | HStack(spacing: 8) {
90 | ForEach(0.. Void
14 | let onStopTapped: () -> Void
15 |
16 | let onBreakTapped: () -> Void
17 |
18 | var breakMessage: String {
19 | return "Hold to" + (isBreakActive ? " Stop Break" : " Start Break")
20 | }
21 |
22 | var breakColor: Color? {
23 | return isBreakActive ? .orange : nil
24 | }
25 |
26 | var body: some View {
27 | VStack(spacing: 8) {
28 | HStack(spacing: 8) {
29 | if isActive, let elapsedTimeVal = elapsedTime {
30 | // Timer
31 | HStack(spacing: 8) {
32 | Text(timeString(from: elapsedTimeVal))
33 | .foregroundColor(.primary)
34 | .font(.system(size: 16, weight: .semibold))
35 | .contentTransition(.numericText())
36 | .animation(.default, value: elapsedTimeVal)
37 | }
38 | .padding(.vertical, 10)
39 | .padding(.horizontal, 12)
40 | .frame(minWidth: 0, maxWidth: .infinity)
41 | .background(
42 | RoundedRectangle(cornerRadius: 16)
43 | .fill(.thinMaterial)
44 | .overlay(
45 | RoundedRectangle(cornerRadius: 16)
46 | .stroke(
47 | themeManager.themeColor.opacity(0.2),
48 | lineWidth: 1
49 | )
50 | )
51 | )
52 |
53 | // Stop button
54 | GlassButton(
55 | title: "Stop",
56 | icon: "stop.fill",
57 | fullWidth: false,
58 | equalWidth: true
59 | ) {
60 | UIImpactFeedbackGenerator(style: .light).impactOccurred()
61 | onStopTapped()
62 | }
63 | } else {
64 | // Start button (full width when no timer is shown)
65 | GlassButton(
66 | title: "Hold to Start",
67 | icon: "play.fill",
68 | fullWidth: true,
69 | longPressEnabled: true
70 | ) {
71 | onStartTapped()
72 | }
73 | }
74 | }
75 |
76 | if isBreakAvailable {
77 | GlassButton(
78 | title: breakMessage,
79 | icon: "cup.and.heat.waves.fill",
80 | fullWidth: true,
81 | longPressEnabled: true,
82 | color: breakColor
83 | ) {
84 | onBreakTapped()
85 | }
86 | }
87 | }
88 | }
89 |
90 | // Format TimeInterval to HH:MM:SS
91 | private func timeString(from timeInterval: TimeInterval) -> String {
92 | let hours = Int(timeInterval) / 3600
93 | let minutes = Int(timeInterval) / 60 % 60
94 | let seconds = Int(timeInterval) % 60
95 | return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
96 | }
97 | }
98 |
99 | #Preview {
100 | VStack(spacing: 20) {
101 | ProfileTimerButton(
102 | isActive: false,
103 | isBreakAvailable: false,
104 | isBreakActive: false,
105 | elapsedTime: nil,
106 | onStartTapped: {},
107 | onStopTapped: {},
108 | onBreakTapped: {}
109 | )
110 |
111 | ProfileTimerButton(
112 | isActive: true,
113 | isBreakAvailable: true,
114 | isBreakActive: false,
115 | elapsedTime: 3665,
116 | onStartTapped: {},
117 | onStopTapped: {},
118 | onBreakTapped: {}
119 | )
120 | }
121 | .padding()
122 | .background(Color(.systemGroupedBackground))
123 | }
124 |
--------------------------------------------------------------------------------
/Foqos/Components/BlockedProfileCards/ProfileScheduleRow.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct ProfileScheduleRow: View {
4 | let profile: BlockedProfiles
5 | let isActive: Bool
6 |
7 | private var hasSchedule: Bool { profile.schedule?.isActive == true }
8 |
9 | private var isTimerStrategy: Bool {
10 | profile.blockingStrategyId == NFCTimerBlockingStrategy.id
11 | || profile.blockingStrategyId == QRTimerBlockingStrategy.id
12 | }
13 |
14 | private var timerDuration: Int? {
15 | guard let strategyData = profile.strategyData else { return nil }
16 | let timerData = StrategyTimerData.toStrategyTimerData(from: strategyData)
17 | return timerData.durationInMinutes
18 | }
19 |
20 | private var daysLine: String {
21 | guard let schedule = profile.schedule, schedule.isActive else {
22 | return ""
23 | }
24 | return schedule.days
25 | .sorted { $0.rawValue < $1.rawValue }
26 | .map { $0.shortLabel }
27 | .joined(separator: " ")
28 | }
29 |
30 | private var timeLine: String? {
31 | guard let schedule = profile.schedule, schedule.isActive else { return nil }
32 | let start = formattedTimeString(hour24: schedule.startHour, minute: schedule.startMinute)
33 | let end = formattedTimeString(hour24: schedule.endHour, minute: schedule.endMinute)
34 | return "\(start) - \(end)"
35 | }
36 |
37 | private func formattedTimeString(hour24: Int, minute: Int) -> String {
38 | var hour = hour24 % 12
39 | if hour == 0 { hour = 12 }
40 | let isPM = hour24 >= 12
41 | return "\(hour):\(String(format: "%02d", minute)) \(isPM ? "PM" : "AM")"
42 | }
43 |
44 | var body: some View {
45 | HStack(spacing: 4) {
46 | // Icon
47 | Group {
48 | if profile.scheduleIsOutOfSync || (hasSchedule && isTimerStrategy) {
49 | Image(systemName: "exclamationmark.triangle.fill")
50 | .foregroundColor(.red)
51 | }
52 | }
53 | .font(.body)
54 |
55 | VStack(alignment: .leading, spacing: 2) {
56 | if profile.scheduleIsOutOfSync {
57 | Text("Schedule is Out of Sync")
58 | .font(.caption2)
59 | } else if !hasSchedule && isActive && isTimerStrategy {
60 | Text("Duration")
61 | .font(.caption)
62 | .fontWeight(.semibold)
63 | .foregroundColor(.primary)
64 |
65 | if let duration = timerDuration {
66 | Text("\(DateFormatters.formatMinutes(duration))")
67 | .font(.caption2)
68 | .foregroundColor(.secondary)
69 | }
70 | } else if hasSchedule && isTimerStrategy {
71 | Text("Unstable Profile with Schedule")
72 | .font(.caption2)
73 | } else if !hasSchedule {
74 | Text("No Schedule Set")
75 | .font(.caption)
76 | .foregroundColor(.secondary)
77 | } else if hasSchedule {
78 | Text(daysLine)
79 | .font(.caption)
80 | .fontWeight(.semibold)
81 | .foregroundColor(.primary)
82 |
83 | if let timeLine = timeLine {
84 | Text(timeLine)
85 | .font(.caption2)
86 | .foregroundColor(.secondary)
87 | }
88 | }
89 | }
90 |
91 | Spacer(minLength: 0)
92 | }
93 | }
94 | }
95 |
96 | #Preview {
97 | VStack(spacing: 20) {
98 | ProfileScheduleRow(
99 | profile: BlockedProfiles(
100 | name: "Test",
101 | blockingStrategyId: NFCBlockingStrategy.id,
102 | schedule: .init(
103 | days: [.monday, .wednesday, .friday],
104 | startHour: 9,
105 | startMinute: 0,
106 | endHour: 17,
107 | endMinute: 0,
108 | updatedAt: Date()
109 | )
110 | ),
111 | isActive: false
112 | )
113 | }
114 | .padding()
115 | .background(Color(.systemGroupedBackground))
116 | }
117 |
--------------------------------------------------------------------------------
/foqos.xcodeproj/xcshareddata/xcschemes/FoqosDeviceMonitor.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
6 |
10 |
11 |
17 |
23 |
24 |
25 |
31 |
37 |
38 |
39 |
40 |
41 |
47 |
48 |
60 |
62 |
68 |
69 |
70 |
71 |
79 |
81 |
87 |
88 |
89 |
90 |
92 |
93 |
96 |
97 |
98 |
--------------------------------------------------------------------------------
/Foqos/Components/Strategy/QRCodeScanner.swift:
--------------------------------------------------------------------------------
1 | import CodeScanner
2 | import SwiftUI
3 |
4 | struct LabeledCodeScannerView: View {
5 | let heading: String
6 | let subtitle: String
7 | let simulatedData: String?
8 | let onScanResult: (Result) -> Void
9 |
10 | @State private var isShowingScanner = true
11 | @State private var errorMessage: String? = nil
12 | @State private var isTorchOn = false
13 |
14 | init(
15 | heading: String,
16 | subtitle: String,
17 | simulatedData: String? = nil,
18 | onScanResult: @escaping (Result) -> Void
19 | ) {
20 | self.heading = heading
21 | self.subtitle = subtitle
22 | self.simulatedData = simulatedData
23 | self.onScanResult = onScanResult
24 | }
25 |
26 | var body: some View {
27 | VStack(alignment: .leading) {
28 | Text(heading)
29 | .font(.title2)
30 | .bold()
31 | Text(subtitle)
32 | .font(.subheadline)
33 | .foregroundColor(.gray)
34 | .padding(.bottom)
35 |
36 | if isShowingScanner {
37 | ZStack(alignment: .bottomTrailing) {
38 | CodeScannerView(
39 | codeTypes: [
40 | .aztec,
41 | .code128,
42 | .code39,
43 | .code39Mod43,
44 | .code93,
45 | .ean8,
46 | .ean13,
47 | .interleaved2of5,
48 | .itf14,
49 | .pdf417,
50 | .upce,
51 | .qr,
52 | .dataMatrix,
53 | ],
54 | showViewfinder: true,
55 | shouldVibrateOnSuccess: true,
56 | isTorchOn: isTorchOn,
57 | completion: handleScanResult
58 | )
59 | .frame(maxWidth: .infinity, maxHeight: .infinity)
60 | .cornerRadius(12)
61 |
62 | // Flashlight toggle button
63 | Button(action: {
64 | isTorchOn.toggle()
65 | }) {
66 | Image(systemName: isTorchOn ? "flashlight.on.fill" : "flashlight.slash")
67 | .font(.system(size: 24))
68 | .foregroundColor(.white)
69 | .padding(12)
70 | .background(Color.black.opacity(0.6))
71 | .clipShape(Circle())
72 | }
73 | .padding(16)
74 | }
75 | .padding(.vertical, 10)
76 | } else if let errorMessage = errorMessage {
77 | Text("Error: \(errorMessage)")
78 | .foregroundColor(.red)
79 | .padding()
80 | } else {
81 |
82 | Text("Scanner Paused or Not Available")
83 | .foregroundColor(.secondary)
84 | .padding()
85 | }
86 |
87 | Spacer()
88 | }
89 | .padding()
90 | .onAppear {
91 | isShowingScanner = true
92 | errorMessage = nil
93 | isTorchOn = false
94 | }
95 | .onDisappear {
96 | isShowingScanner = false
97 | isTorchOn = false
98 | }
99 | }
100 |
101 | private func handleScanResult(_ result: Result) {
102 | switch result {
103 | case .success(let scanResult):
104 | isShowingScanner = false
105 | errorMessage = nil
106 | onScanResult(.success(scanResult))
107 | case .failure(let error):
108 | isShowingScanner = false
109 | errorMessage = error.localizedDescription
110 | onScanResult(.failure(error))
111 | }
112 | }
113 | }
114 |
115 | #Preview { // Using the #Preview macro
116 | LabeledCodeScannerView(
117 | heading: "Scan QR Code",
118 | subtitle: "Point your camera at a QR code to activate a feature.",
119 | simulatedData: "Simulated QR Code Data for Preview" // For preview purposes
120 | ) { result in
121 | switch result {
122 | case .success(let result):
123 | print("Preview Scanned code: \(result.string)")
124 | case .failure(let error):
125 | print("Preview Scanning failed: \(error.localizedDescription)")
126 | }
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/Foqos/Components/Intro/PermissionsIntroScreen.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct PermissionsIntroScreen: View {
4 | @State private var showContent: Bool = false
5 | @State private var shieldScale: CGFloat = 0.5
6 | @State private var pulseAnimation: Bool = false
7 |
8 | var body: some View {
9 | VStack(spacing: 0) {
10 | // Heading
11 | // Header
12 | VStack(spacing: 8) {
13 | Text("One Last Step")
14 | .font(.system(size: 34, weight: .bold))
15 | .foregroundColor(.primary)
16 | .opacity(showContent ? 1 : 0)
17 | .offset(y: showContent ? 0 : -20)
18 |
19 | Text("We need Screen Time Access to get started")
20 | .font(.system(size: 16))
21 | .foregroundColor(.secondary)
22 | .opacity(showContent ? 1 : 0)
23 | .offset(y: showContent ? 0 : -20)
24 | }
25 |
26 | Spacer()
27 |
28 | // Shield icon with pulse animation
29 | ZStack {
30 | // Pulse rings
31 | ForEach(0..<2) { index in
32 | Circle()
33 | .stroke(
34 | LinearGradient(
35 | gradient: Gradient(colors: [
36 | Color.accentColor.opacity(0.3),
37 | Color.accentColor.opacity(0.1),
38 | ]),
39 | startPoint: .topLeading,
40 | endPoint: .bottomTrailing
41 | ),
42 | lineWidth: 3
43 | )
44 | .frame(width: 220, height: 220)
45 | .scaleEffect(pulseAnimation ? 1.3 : 1.0)
46 | .opacity(pulseAnimation ? 0 : 0.6)
47 | .animation(
48 | .easeOut(duration: 2)
49 | .repeatForever(autoreverses: false)
50 | .delay(Double(index) * 0.6),
51 | value: pulseAnimation
52 | )
53 | }
54 |
55 | // Shield icon
56 | Image("ShieldIcon")
57 | .resizable()
58 | .scaledToFit()
59 | .frame(width: 200, height: 200)
60 | .scaleEffect(shieldScale)
61 | .opacity(showContent ? 1 : 0)
62 | }
63 | .frame(height: 360)
64 |
65 | Spacer()
66 |
67 | // Message text
68 | VStack(spacing: 16) {
69 | (Text("Foqos is 100% open source, ")
70 | + Text("read the code yourself")
71 | .foregroundColor(.accentColor)
72 | + Text(
73 | " if you're skeptical. We don't care who you are, we just want you to live with focus and intention."
74 | ))
75 | .font(.system(size: 16, weight: .medium))
76 | .foregroundColor(.secondary)
77 | .multilineTextAlignment(.center)
78 | .lineSpacing(4)
79 | .onTapGesture {
80 | if let url = URL(string: "https://github.com/awaseem/foqos") {
81 | UIApplication.shared.open(url)
82 | }
83 | }
84 |
85 | Text("No account required. No subscription fees. No tracking. No BS.")
86 | .font(.system(size: 16, weight: .medium))
87 | .foregroundColor(.secondary)
88 | .multilineTextAlignment(.center)
89 | .lineSpacing(4)
90 | }
91 | .padding(.horizontal, 10)
92 | .opacity(showContent ? 1 : 0)
93 | .offset(y: showContent ? 0 : 20)
94 | }
95 | .frame(maxWidth: .infinity, maxHeight: .infinity)
96 | .onAppear {
97 | // Shield scale animation
98 | withAnimation(.spring(response: 0.8, dampingFraction: 0.6, blendDuration: 0).delay(0.2)) {
99 | shieldScale = 1.0
100 | }
101 |
102 | // Content fade in
103 | withAnimation(.easeOut(duration: 0.6).delay(0.3)) {
104 | showContent = true
105 | }
106 |
107 | // Start pulse animation
108 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
109 | pulseAnimation = true
110 | }
111 | }
112 | }
113 | }
114 |
115 | #Preview {
116 | PermissionsIntroScreen()
117 | .background(Color(.systemBackground))
118 | }
119 |
--------------------------------------------------------------------------------
/Foqos/Models/Timers/ScheduleTimerActivity.swift:
--------------------------------------------------------------------------------
1 | import DeviceActivity
2 | import OSLog
3 |
4 | private let log: Logger = Logger(subsystem: "com.foqos.monitor", category: ScheduleTimerActivity.id)
5 |
6 | class ScheduleTimerActivity: TimerActivity {
7 | static var id: String = "ScheduleTimerActivity"
8 |
9 | private let appBlocker = AppBlockerUtil()
10 |
11 | func getDeviceActivityName(from profileId: String) -> DeviceActivityName {
12 | // Since schedules were implemented before the timer activities, the profile id is used as the device activity name for
13 | // backward compatibility
14 | return DeviceActivityName(rawValue: profileId)
15 | }
16 |
17 | func getAllScheduleTimerActivities(from activities: [DeviceActivityName]) -> [DeviceActivityName]
18 | {
19 | // Schedule timer activities use just the profile UUID as the rawValue (no prefix)
20 | // Other activities use prefixes like "BreakScheduleActivity:" or "StrategyTimerActivity:"
21 | return activities.filter { activity in
22 | let rawValue = activity.rawValue
23 | // If it contains ":", it's a prefixed activity (break or strategy timer), not a schedule
24 | guard !rawValue.contains(":") else { return false }
25 | // Must be a valid UUID
26 | return UUID(uuidString: rawValue) != nil
27 | }
28 | }
29 |
30 | func start(for profile: SharedData.ProfileSnapshot) {
31 | let profileId = profile.id.uuidString
32 |
33 | guard let schedule = profile.schedule
34 | else {
35 | log.info("Start schedule timer activity for \(profileId), no schedule for profile found")
36 | return
37 | }
38 |
39 | if !schedule.isTodayScheduled() {
40 | log.info(
41 | "Start schedule timer activity for \(profileId), schedule is not scheduled for today")
42 | return
43 | }
44 |
45 | if !schedule.olderThan15Minutes() {
46 | log.info("Start schedule timer activity for \(profileId), schedule is too new")
47 | return
48 | }
49 |
50 | log.info("Start schedule timer activity for \(profileId), profile: \(profileId)")
51 |
52 | if let existingSession = SharedData.getActiveSharedSession() {
53 | if existingSession.blockedProfileId == profile.id {
54 | log.info(
55 | "Start schedule timer activity for \(profileId), existing session profile matches device activity profile, continuing active session"
56 | )
57 | return
58 | } else {
59 | log.info(
60 | "Start schedule timer activity for \(profileId), existing session profile does not match device activity profile, ending active session"
61 | )
62 | SharedData.endActiveSharedSession()
63 | }
64 | }
65 |
66 | // Create a new active scheduled session for the profile
67 | SharedData.createSessionForSchedular(for: profile.id)
68 |
69 | // Start restrictions
70 | appBlocker.activateRestrictions(for: profile)
71 | }
72 |
73 | func stop(for profile: SharedData.ProfileSnapshot) {
74 | let profileId = profile.id.uuidString
75 |
76 | guard let activeSession = SharedData.getActiveSharedSession() else {
77 | log.info("Stop schedule timer activity for \(profileId), no active session found")
78 | return
79 | }
80 |
81 | // Check to make sure the active session is the same as the profile before disabling restrictions
82 | if activeSession.blockedProfileId != profile.id {
83 | log.info(
84 | "Stop schedule timer activity for \(profileId), active session profile does not match device activity profile"
85 | )
86 | return
87 | }
88 |
89 | // End restrictions
90 | appBlocker.deactivateRestrictions()
91 |
92 | // End the active scheduled session
93 | SharedData.endActiveSharedSession()
94 | }
95 |
96 | func getScheduleInterval(from schedule: BlockedProfileSchedule) -> (
97 | intervalStart: DateComponents, intervalEnd: DateComponents
98 | ) {
99 | let intervalStart = DateComponents(hour: schedule.startHour, minute: schedule.startMinute)
100 | let intervalEnd = DateComponents(hour: schedule.endHour, minute: schedule.endMinute)
101 | return (intervalStart: intervalStart, intervalEnd: intervalEnd)
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/Foqos/Components/Intro/WelcomeIntroScreen.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | let ORBIT_OFFSET: CGFloat = 145
4 |
5 | struct WelcomeIntroScreen: View {
6 | @State private var logoScale: CGFloat = 0.5
7 | @State private var showContent: Bool = false
8 | @State private var showIcons: Bool = false
9 | @State private var orbitRotation: Double = 0
10 |
11 | var body: some View {
12 | VStack(spacing: 0) {
13 | // Heading
14 | VStack(spacing: 8) {
15 | Text("Welcome to Foqos")
16 | .font(.system(size: 34, weight: .bold))
17 | .foregroundColor(.primary)
18 | .opacity(showContent ? 1 : 0)
19 | .offset(y: showContent ? 0 : -20)
20 |
21 | Text("Live your best life with focus and intention.")
22 | .font(.system(size: 16))
23 | .foregroundColor(.secondary)
24 | .opacity(showContent ? 1 : 0)
25 | .offset(y: showContent ? 0 : -20)
26 | }
27 |
28 | Spacer()
29 |
30 | // Logo container with orbiting icons
31 | ZStack {
32 | // Orbiting NFC Logo (0 degrees)
33 | Image("NFCLogo")
34 | .resizable()
35 | .scaledToFit()
36 | .frame(width: 50, height: 50)
37 | .offset(x: ORBIT_OFFSET) // Orbit radius
38 | .rotationEffect(.degrees(orbitRotation))
39 | .opacity(showIcons ? 1 : 0)
40 |
41 | // Orbiting Barcode Icon (90 degrees)
42 | Image("BarcodeIcon")
43 | .resizable()
44 | .scaledToFit()
45 | .frame(width: 50, height: 50)
46 | .offset(x: ORBIT_OFFSET) // Orbit radius
47 | .rotationEffect(.degrees(orbitRotation + 90))
48 | .opacity(showIcons ? 1 : 0)
49 |
50 | // Orbiting QR Code Logo (180 degrees)
51 | Image("QRCodeLogo")
52 | .resizable()
53 | .scaledToFit()
54 | .frame(width: 50, height: 50)
55 | .offset(x: ORBIT_OFFSET) // Orbit radius
56 | .rotationEffect(.degrees(orbitRotation + 180))
57 | .opacity(showIcons ? 1 : 0)
58 |
59 | // Orbiting Schedule Icon (270 degrees)
60 | Image("ScheduleIcon")
61 | .resizable()
62 | .scaledToFit()
63 | .frame(width: 50, height: 50)
64 | .offset(x: ORBIT_OFFSET) // Orbit radius
65 | .rotationEffect(.degrees(orbitRotation + 270))
66 | .opacity(showIcons ? 1 : 0)
67 |
68 | // 3D Logo (center/sun)
69 | Image("3DFoqosLogo")
70 | .resizable()
71 | .scaledToFit()
72 | .frame(width: 200, height: 200)
73 | .scaleEffect(logoScale)
74 | .opacity(showContent ? 1 : 0)
75 | }
76 | .frame(height: 360)
77 |
78 | Spacer()
79 |
80 | // Message text
81 | VStack(spacing: 12) {
82 | Text(
83 | "No need to waste hundreds on gimmicky plastic bricks and overpriced metal cards."
84 | )
85 | .font(.system(size: 18, weight: .medium))
86 | .foregroundColor(.secondary)
87 | .multilineTextAlignment(.center)
88 | .lineSpacing(4)
89 | }
90 | .padding(.horizontal, 15)
91 | .opacity(showContent ? 1 : 0)
92 | .offset(y: showContent ? 0 : 20)
93 | }
94 | .frame(maxWidth: .infinity, maxHeight: .infinity)
95 | .onAppear {
96 | // Logo scale animation (0.8s spring animation with 0.2s delay = 1.0s total)
97 | withAnimation(.spring(response: 0.8, dampingFraction: 0.6, blendDuration: 0).delay(0.2)) {
98 | logoScale = 1.0
99 | }
100 |
101 | // Content fade in
102 | withAnimation(.easeOut(duration: 0.6).delay(0.3)) {
103 | showContent = true
104 | }
105 |
106 | // Show icons after logo animation completes (1.0s delay)
107 | withAnimation(.easeIn(duration: 0.2).delay(1.0)) {
108 | showIcons = true
109 | }
110 |
111 | // Start continuous orbit animation after icons appear
112 | withAnimation(.linear(duration: 10).repeatForever(autoreverses: false)) {
113 | orbitRotation = 360
114 | }
115 | }
116 | }
117 | }
118 |
119 | #Preview {
120 | WelcomeIntroScreen()
121 | .background(Color(.systemBackground))
122 | }
123 |
--------------------------------------------------------------------------------
/Foqos/Components/Debug/ProfileDebugCard.swift:
--------------------------------------------------------------------------------
1 | import SwiftData
2 | import SwiftUI
3 |
4 | struct ProfileDebugCard: View {
5 | let profile: BlockedProfiles
6 |
7 | var body: some View {
8 | VStack(alignment: .leading, spacing: 8) {
9 | // Basic Info
10 | Group {
11 | DebugRow(label: "ID", value: profile.id.uuidString)
12 | DebugRow(label: "Name", value: profile.name)
13 | DebugRow(label: "Created At", value: DateFormatters.formatDate(profile.createdAt))
14 | DebugRow(label: "Updated At", value: DateFormatters.formatDate(profile.updatedAt))
15 | DebugRow(label: "Order", value: "\(profile.order)")
16 | }
17 |
18 | Divider()
19 |
20 | // Strategy & Features
21 | Group {
22 | DebugRow(label: "Strategy ID", value: profile.blockingStrategyId ?? "nil")
23 | DebugRow(label: "Enable Live Activity", value: "\(profile.enableLiveActivity)")
24 | DebugRow(label: "Enable Breaks", value: "\(profile.enableBreaks)")
25 | DebugRow(label: "Enable Strict Mode", value: "\(profile.enableStrictMode)")
26 | DebugRow(label: "Enable Allow Mode", value: "\(profile.enableAllowMode)")
27 | DebugRow(
28 | label: "Enable Allow Mode Domains",
29 | value: "\(profile.enableAllowModeDomains)"
30 | )
31 | DebugRow(
32 | label: "Disable Background Stops",
33 | value: "\(profile.disableBackgroundStops)"
34 | )
35 | }
36 |
37 | Divider()
38 |
39 | // Reminders
40 | Group {
41 | DebugRow(
42 | label: "Reminder Time (seconds)",
43 | value: profile.reminderTimeInSeconds.map { "\($0)" } ?? "nil"
44 | )
45 | DebugRow(
46 | label: "Custom Reminder Message",
47 | value: profile.customReminderMessage ?? "nil"
48 | )
49 | }
50 |
51 | Divider()
52 |
53 | // Physical Unlock
54 | Group {
55 | DebugRow(label: "NFC Tag ID", value: profile.physicalUnblockNFCTagId ?? "nil")
56 | DebugRow(label: "QR Code ID", value: profile.physicalUnblockQRCodeId ?? "nil")
57 | }
58 |
59 | Divider()
60 |
61 | // Sessions & Activity
62 | Group {
63 | DebugRow(label: "Total Sessions", value: "\(profile.sessions.count)")
64 | DebugRow(
65 | label: "Active Schedule Timer Activity",
66 | value: profile.activeScheduleTimerActivity?.rawValue ?? "nil"
67 | )
68 | }
69 | }
70 | }
71 | }
72 |
73 | #Preview {
74 | let profile = BlockedProfiles(
75 | name: "Work Focus",
76 | blockingStrategyId: NFCBlockingStrategy.id,
77 | enableLiveActivity: true,
78 | reminderTimeInSeconds: 3600,
79 | customReminderMessage: "Time to focus!",
80 | enableBreaks: true,
81 | enableStrictMode: false,
82 | enableAllowMode: false,
83 | order: 0
84 | )
85 |
86 | return ProfileDebugCard(profile: profile)
87 | .padding()
88 | .modelContainer(for: [BlockedProfiles.self, BlockedProfileSession.self])
89 | }
90 |
91 | #Preview("Profile with NFC Tag") {
92 | let profile = BlockedProfiles(
93 | name: "Deep Work",
94 | blockingStrategyId: NFCBlockingStrategy.id,
95 | enableLiveActivity: false,
96 | enableBreaks: false,
97 | enableStrictMode: true,
98 | order: 1,
99 | physicalUnblockNFCTagId: "ABC123DEF456"
100 | )
101 |
102 | return ProfileDebugCard(profile: profile)
103 | .padding()
104 | .modelContainer(for: [BlockedProfiles.self, BlockedProfileSession.self])
105 | }
106 |
107 | #Preview("Profile with Schedule") {
108 | let schedule = BlockedProfileSchedule(
109 | days: [.monday, .tuesday, .wednesday, .thursday, .friday],
110 | startHour: 9,
111 | startMinute: 0,
112 | endHour: 17,
113 | endMinute: 0
114 | )
115 |
116 | let profile = BlockedProfiles(
117 | name: "Scheduled Focus",
118 | blockingStrategyId: ManualBlockingStrategy.id,
119 | enableLiveActivity: true,
120 | enableBreaks: true,
121 | order: 2,
122 | schedule: schedule,
123 | disableBackgroundStops: true
124 | )
125 |
126 | return ProfileDebugCard(profile: profile)
127 | .padding()
128 | .modelContainer(for: [BlockedProfiles.self, BlockedProfileSession.self])
129 | }
130 |
--------------------------------------------------------------------------------
/foqos.xcodeproj/xcshareddata/xcschemes/foqos.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
10 |
16 |
22 |
23 |
24 |
25 |
26 |
32 |
33 |
36 |
42 |
43 |
44 |
47 |
53 |
54 |
55 |
56 |
57 |
67 |
69 |
75 |
76 |
77 |
79 |
80 |
81 |
87 |
89 |
95 |
96 |
97 |
98 |
100 |
101 |
104 |
105 |
106 |
--------------------------------------------------------------------------------
/Foqos/Components/Intro/FeaturesIntroScreen.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct FeaturesIntroScreen: View {
4 | @State private var selectedFeature: Int = 0
5 | @State private var showContent: Bool = false
6 |
7 | let features = [
8 | Feature(
9 | imageName: "NFCLogo",
10 | title: "NFC Tags",
11 | description:
12 | "Tap your phone on an NFC tag to instantly start or stop a focus session. You can buy them on Amazon for a few dollars."
13 | ),
14 | Feature(
15 | imageName: "QRCodeLogo",
16 | title: "QR Codes",
17 | description:
18 | "Scan a QR code to control your focus sessions. Place codes around your space to create intentional focus triggers. You can even use barcodes."
19 | ),
20 | Feature(
21 | imageName: "ScheduleIcon",
22 | title: "Smart Schedules",
23 | description:
24 | "Set up automatic focus sessions based on your routine. Create schedules for work, study, personal time, and more."
25 | ),
26 | ]
27 |
28 | var body: some View {
29 | VStack(spacing: 0) {
30 | // Header
31 | VStack(spacing: 8) {
32 | Text("Powerful Features")
33 | .font(.system(size: 34, weight: .bold))
34 | .foregroundColor(.primary)
35 | .opacity(showContent ? 1 : 0)
36 | .offset(y: showContent ? 0 : -20)
37 |
38 | Text("Everything you need to stay focused")
39 | .font(.system(size: 16))
40 | .foregroundColor(.secondary)
41 | .opacity(showContent ? 1 : 0)
42 | .offset(y: showContent ? 0 : -20)
43 | }
44 |
45 | Spacer()
46 |
47 | // Feature selector and display
48 | VStack(spacing: 0) {
49 | // Icon selector
50 | HStack(spacing: 20) {
51 | ForEach(0..()
6 | @Published var purchaseError: String?
7 |
8 | @Published var loadingTip = false
9 |
10 | private let productID = "tip_developer_support"
11 |
12 | // Computed property to check if tip has been purchased
13 | var hasPurchasedTip: Bool {
14 | return purchasedProductIDs.contains(productID)
15 | }
16 |
17 | init() {
18 | Task {
19 | await loadProducts()
20 | await setupTransactionListener()
21 | }
22 | }
23 |
24 | @MainActor
25 | private func setupTransactionListener() async {
26 | // Start a transaction listener as soon as the app launches
27 | let updates = Transaction.updates
28 | for await result in updates {
29 | do {
30 | switch result {
31 | case .verified(let transaction):
32 | // Handle a verified transaction
33 | await handleVerifiedTransaction(transaction)
34 | case .unverified(let transaction, let error):
35 | // Log the unverified transaction for debugging
36 | purchaseError =
37 | "Verification failed: \(error.localizedDescription)"
38 | print(
39 | "Unverified transaction: \(transaction.id), Error: \(error)"
40 | )
41 | }
42 | }
43 | }
44 | }
45 |
46 | @MainActor
47 | private func handleVerifiedTransaction(_ transaction: Transaction) async {
48 | // Add the purchased product identifier to the purchased set
49 | purchasedProductIDs.insert(transaction.productID)
50 |
51 | // Clear any previous error since the purchase was successful
52 | purchaseError = nil
53 |
54 | // Always finish a transaction once you've delivered the content
55 | await transaction.finish()
56 |
57 | // Update any UI or app state based on the purchase
58 | NotificationCenter.default.post(
59 | name: NSNotification.Name("PurchaseSuccessful"),
60 | object: nil
61 | )
62 | }
63 |
64 | @MainActor
65 | func loadProducts() async {
66 | do {
67 | // Request products from the App Store
68 | products = try await Product.products(for: [productID])
69 |
70 | // Check current entitlements
71 | await checkEntitlements()
72 |
73 | // Debug logging
74 | print(
75 | "Available products: \(products.map { $0.id }.joined(separator: ", "))"
76 | )
77 | } catch {
78 | purchaseError =
79 | "Failed to load products: \(error.localizedDescription)"
80 | print("Product loading error: \(error)")
81 | }
82 | }
83 |
84 | @MainActor
85 | private func checkEntitlements() async {
86 | // Verify existing purchases
87 | for await result in Transaction.currentEntitlements {
88 | switch result {
89 | case .verified(let transaction):
90 | purchasedProductIDs.insert(transaction.productID)
91 | case .unverified:
92 | continue
93 | }
94 | }
95 | }
96 |
97 | @MainActor
98 | func purchase() async throws {
99 | guard let product = products.first else {
100 | throw StoreError.noProduct
101 | }
102 |
103 | // Begin a purchase
104 | let result = try await product.purchase()
105 |
106 | switch result {
107 | case .success(let verificationResult):
108 | switch verificationResult {
109 | case .verified(let transaction):
110 | // Handle successful purchase
111 | purchasedProductIDs.insert(transaction.productID)
112 | purchaseError = nil // Clear error on successful purchase
113 | Task {
114 | await transaction.finish()
115 | }
116 | case .unverified(_, let error):
117 | purchaseError =
118 | "Purchase verification failed: \(error.localizedDescription)"
119 | }
120 | case .userCancelled:
121 | purchaseError = "Purchase was cancelled"
122 | case .pending:
123 | purchaseError = "Purchase is pending"
124 | @unknown default:
125 | purchaseError = "Unknown purchase result"
126 | }
127 | }
128 |
129 | @MainActor
130 | func tip() {
131 | Task {
132 | loadingTip = true
133 | purchaseError = nil // Clear any previous error
134 |
135 | do {
136 | try await purchase()
137 | } catch StoreError.noProduct {
138 | purchaseError = "No product available for purchase"
139 | } catch {
140 | purchaseError = "Purchase failed: \(error.localizedDescription)"
141 | }
142 |
143 | loadingTip = false
144 | }
145 | }
146 | }
147 |
148 | enum StoreError: Error {
149 | case noProduct
150 | }
151 |
--------------------------------------------------------------------------------