├── 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 | --------------------------------------------------------------------------------