├── .gitignore ├── Changelog.md ├── Images └── Screenshots │ ├── iPhone 11 Pro Max - Groups.jpeg │ ├── iPhone 11 Pro Max - Tickmate.jpeg │ ├── iPhone 11 Pro Max - Track.jpeg │ ├── iPhone 11 Pro Max - Tracks.jpeg │ ├── iPhone 11 Pro Max - Widget.jpg │ └── iPhone 11 Pro Max - iCloud.jpeg ├── LICENSE ├── Privacy Policy.txt ├── README.md └── Tickmate ├── Tickmate.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── Tickmate.xcscheme │ ├── TicksWidgetExtension.xcscheme │ └── TicksWidgetIntentsExtension.xcscheme ├── Tickmate ├── Controllers │ ├── GroupController.swift │ ├── Persistence.swift │ ├── StoreController.swift │ ├── TickController.swift │ ├── TrackController.swift │ └── ViewControllerContainer.swift ├── Info.plist ├── Model │ ├── Binding+onChange.swift │ ├── Defaults.swift │ ├── Tickmate+Convenience.swift │ ├── Tickmate+Wrapping.swift │ ├── Tickmate.xcdatamodeld │ │ ├── .xccurrentversion │ │ └── Tickmate.xcdatamodel │ │ │ └── contents │ ├── TrackGroups.swift │ └── TrackRepresentation.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Resources │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── icon-40.png │ │ │ ├── icon-40@2x.png │ │ │ ├── icon-40@3x.png │ │ │ ├── icon-60@2x.png │ │ │ ├── icon-60@3x.png │ │ │ ├── icon-72.png │ │ │ ├── icon-72@2x.png │ │ │ ├── icon-76.png │ │ │ ├── icon-76@2x.png │ │ │ ├── icon-83.5@2x.png │ │ │ ├── icon-small-50.png │ │ │ ├── icon-small-50@2x.png │ │ │ ├── icon-small.png │ │ │ ├── icon-small@2x.png │ │ │ ├── icon-small@3x.png │ │ │ ├── icon.png │ │ │ ├── icon@2x.png │ │ │ ├── ios-marketing.png │ │ │ ├── notification-icon@2x.png │ │ │ ├── notification-icon@3x.png │ │ │ ├── notification-icon~ipad.png │ │ │ └── notification-icon~ipad@2x.png │ │ ├── AppIcon.solidimagestack │ │ │ ├── Back.solidimagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ │ ├── Background.png │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ ├── Front.solidimagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ │ ├── Contents.json │ │ │ │ │ └── Ticks.png │ │ │ │ └── Contents.json │ │ │ └── Middle.solidimagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── Dividers.png │ │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Configuration.storekit │ ├── Launch Screen.storyboard │ ├── PresetTracks.swift │ └── SymbolsList.swift ├── Supplementary Views │ ├── AcknowledgementLink.swift │ ├── AlertItem.swift │ ├── Animation+Push.swift │ ├── Color+RGB.swift │ ├── PageView.swift │ ├── StateEditButton.swift │ ├── TextWithCaption.swift │ ├── View+Centered.swift │ ├── View+Conditional.swift │ └── View+DismissKeyboard.swift ├── Tickmate.entitlements ├── TickmateApp.swift └── Views │ ├── AcknowledgementsView.swift │ ├── ArchivedTracksView.swift │ ├── ContentView.swift │ ├── ExportTracksSelectionView.swift │ ├── GroupView.swift │ ├── GroupsView.swift │ ├── OnboardingView.swift │ ├── PresetTracksView.swift │ ├── SettingsView.swift │ ├── ShareSheet.swift │ ├── SymbolPicker.swift │ ├── TickView.swift │ ├── TicksView.swift │ ├── TrackView.swift │ └── TracksView.swift ├── TicksWidget ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ └── WidgetBackground.colorset │ │ └── Contents.json ├── Info.plist ├── TicksWidget.intentdefinition └── TicksWidget.swift ├── TicksWidgetExtension.entitlements └── TicksWidgetIntentsExtension ├── Info.plist ├── IntentHandler.swift └── TicksWidgetIntentsExtension.entitlements /.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 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | # macOS 93 | .DS_Store 94 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # Tickmate Changelog 2 | 3 | ## v1.4 4 | 5 | Released on the App Store 2024/03/17 6 | 7 | + Tracks can now be archived 8 | ([#93](https://github.com/skjiisa/Tickmate-iOS/pull/93)) 9 | 10 | ## v1.3 and v1.3.1 11 | 12 | v1.3 released on the iOS App Store 2024/01/29 13 | v1.3.1 released on the visionOS App Store 2024/02/02 14 | 15 | ### Major changes 16 | 17 | + Native Apple Vision Pro support 18 | 19 | ### Minor improvements 20 | 21 | + Improve future-proofing by only showing known in-app purchases 22 | ([#88](https://github.com/skjiisa/Tickmate-iOS/pull/88)) 23 | + Fix iOS 16 and 17 issues related to Introspect for SwiftUI 24 | ([#89](https://github.com/skjiisa/Tickmate-iOS/pull/89)) 25 | + Fix tracks rearranging themselves on new installs 26 | ([#91](https://github.com/skjiisa/Tickmate-iOS/pull/91)) 27 | + Make swiping between pages less janky (again) 28 | ([a40b9a9](https://github.com/skjiisa/Tickmate-iOS/commit/a40b9a9dc6aab239704391e5666402d8f7735a95)) 29 | 30 | ## v1.2.3 31 | 32 | Released on the App Store 2021/11/04 33 | 34 | + Fix laggy swiping between track groups 35 | ([f7711aa](https://github.com/Isvvc/Tickmate-iOS/commit/f7711aa2aa063f74c441e3e0f0b2abf5dc9fed00)) 36 | 37 | ## v1.2.2 38 | 39 | Released on the App Store 2021/10/04 40 | 41 | ### Minor improvements 42 | 43 | + Sort tracks in the widget by the in-app sort order as opposed to the order they are selected in 44 | ([3fda8c7](https://github.com/Isvvc/Tickmate-iOS/pull/74/commits/3fda8c79826095acf8986522dc9612e6eb054362)) 45 | 46 | #### iOS 15 fixes 47 | 48 | + Fix not being able to open sheets 49 | ([319eb0d](https://github.com/Isvvc/Tickmate-iOS/pull/74/commits/319eb0d880b28e81bba60f5c00ea3ac090ea637a)) 50 | + Fix text fields being empty 51 | ([9bac4d9](https://github.com/Isvvc/Tickmate-iOS/pull/74/commits/9bac4d9c6bf52af7bcdbca208722c0c37ee1ffdc)) 52 | 53 | ## v1.2.1 54 | 55 | Released on the App Store 2021/07/17 56 | 57 | + Fix issue with new tracks not showing in the tracks list 58 | ([e381324](https://github.com/Isvvc/Tickmate-iOS/commit/e3813249cd457132fe258c3df918759bcb10bae0)) 59 | 60 | ## v1.2 61 | 62 | ### Major features 63 | 64 | + Widgets! 65 | ([#51](https://github.com/Isvvc/Tickmate-iOS/pull/51)) 66 | + There are now 3 sizes of customizable widgets for your Home Screen or Today View. 67 | + Fix laggy ticking 68 | ([#50](https://github.com/Isvvc/Tickmate-iOS/issues/50)) 69 | + Ticking days should now happen immediately when tapped 70 | 71 | ### Minor improvements 72 | 73 | + Fix text wrap issue on "Yesterday" text on larger Dynamic Type sizes 74 | ([#48](https://github.com/Isvvc/Tickmate-iOS/issues/48)) 75 | + Add a basic launch screen 76 | ([#49](https://github.com/Isvvc/Tickmate-iOS/issues/49)) 77 | + Add version and build numbers in settings 78 | ([#52](https://github.com/Isvvc/Tickmate-iOS/issues/52)) 79 | + Fix layout of tracks row when large number of tracks exist 80 | ([#54](https://github.com/Isvvc/Tickmate-iOS/issues/54)) 81 | 82 | ## v1.1 83 | 84 | Released 85 | 86 | + 2021/06/23 on GitHub 87 | + 2021/07/09 on the App Store 88 | 89 | ### Major features 90 | 91 | + Track groups 92 | ([#29](https://github.com/Isvvc/Tickmate-iOS/pull/29)) 93 | + In-app purchase that allows you to group tracks together and swipe between groups on the main screen 94 | 95 | ### Minor improvements 96 | 97 | + Fix lag when editing Tracks 98 | ([949a361](https://github.com/Isvvc/Tickmate-iOS/commit/949a3619418b0896a61bbb3dc54569f3b834e41d)) 99 | + Fix issue where date would not update when opening the app on a new day 100 | ([934c875](https://github.com/Isvvc/Tickmate-iOS/commit/934c8755327b20a1636dac4a28b3c0af1dd3eb58)) 101 | + Add new section of preset tracks 102 | ([df74640](https://github.com/Isvvc/Tickmate-iOS/commit/df74640dff3619046ddd1bafa685cd4b597b7b4d), [#46](https://github.com/Isvvc/Tickmate-iOS/pull/46)) 103 | + Fix week separators sometimes not showing correctly or at all 104 | ([#47](https://github.com/Isvvc/Tickmate-iOS/pull/47)) 105 | + Minor back-end bug fixes 106 | ([15bcc73](https://github.com/Isvvc/Tickmate-iOS/commit/15bcc734103871d9e455a107b4edb0592cc9f99e)) 107 | -------------------------------------------------------------------------------- /Images/Screenshots/iPhone 11 Pro Max - Groups.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Images/Screenshots/iPhone 11 Pro Max - Groups.jpeg -------------------------------------------------------------------------------- /Images/Screenshots/iPhone 11 Pro Max - Tickmate.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Images/Screenshots/iPhone 11 Pro Max - Tickmate.jpeg -------------------------------------------------------------------------------- /Images/Screenshots/iPhone 11 Pro Max - Track.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Images/Screenshots/iPhone 11 Pro Max - Track.jpeg -------------------------------------------------------------------------------- /Images/Screenshots/iPhone 11 Pro Max - Tracks.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Images/Screenshots/iPhone 11 Pro Max - Tracks.jpeg -------------------------------------------------------------------------------- /Images/Screenshots/iPhone 11 Pro Max - Widget.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Images/Screenshots/iPhone 11 Pro Max - Widget.jpg -------------------------------------------------------------------------------- /Images/Screenshots/iPhone 11 Pro Max - iCloud.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Images/Screenshots/iPhone 11 Pro Max - iCloud.jpeg -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2025, Elaine Lyons 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /Privacy Policy.txt: -------------------------------------------------------------------------------- 1 | This app does not collect any personal information. 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tickmate 2 | 3 | 1-bit Journal 4 | 5 | Download on the App Store 6 | 7 | ### Screenshots 8 | 9 | 10 | 11 | ## Description 12 | 13 | Tickmate allows you to track any daily occurrences. 14 | It is a is a 1-bit journal, meaning each day is either ticked or not. 15 | You can track anything from habits you wish to build or break to how often you wash your hair. 16 | 17 | + Use a "reversed" track for occurrences you don't want to happen, so they're automatically ticked unless you untick them. 18 | + You can use the "allow multiple" feature to be able to tick a single day multiple times. 19 | + With iCloud sync, all your data will be securely synced across all devices. 20 | + Add customizable widgets for your Home Screen or Today View. 21 | 22 | [Changelog](Changelog.md) 23 | 24 | ### Inspiration 25 | 26 | Tickmate for iOS was designed after, but not affiliated with, [lordi/tickmate](https://github.com/lordi/tickmate) for Android. 27 | 28 | ## Build 29 | 30 | Just set your developer team in Xcode and build! 31 | 32 | Dependencies: 33 | 34 | * [malcommac/SwiftDate](https://github.com/malcommac/SwiftDate) 35 | * [siteline/SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect) 36 | 37 | These dependencies will be automatically fetched by Swift Package Manager in Xcode. 38 | 39 | ## License 40 | 41 | This project is open-source and licensed under the [The 2-Clause BSD License](LICENSE). 42 | -------------------------------------------------------------------------------- /Tickmate/Tickmate.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tickmate/Tickmate.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tickmate/Tickmate.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "3879669beac337797e14d697a5b49a02d41791009205fbb4a311d44492aad7b3", 3 | "pins" : [ 4 | { 5 | "identity" : "swiftdate", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/malcommac/SwiftDate.git", 8 | "state" : { 9 | "revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5", 10 | "version" : "6.3.1" 11 | } 12 | }, 13 | { 14 | "identity" : "swiftui-introspect", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/siteline/SwiftUI-Introspect.git", 17 | "state" : { 18 | "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", 19 | "version" : "1.3.0" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /Tickmate/Tickmate.xcodeproj/xcshareddata/xcschemes/Tickmate.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 54 | 55 | 56 | 62 | 64 | 70 | 71 | 72 | 73 | 75 | 76 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /Tickmate/Tickmate.xcodeproj/xcshareddata/xcschemes/TicksWidgetExtension.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | 10 | 16 | 22 | 23 | 24 | 30 | 36 | 37 | 38 | 39 | 40 | 46 | 47 | 59 | 61 | 67 | 68 | 69 | 70 | 74 | 75 | 79 | 80 | 84 | 85 | 86 | 87 | 95 | 97 | 103 | 104 | 105 | 106 | 108 | 109 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /Tickmate/Tickmate.xcodeproj/xcshareddata/xcschemes/TicksWidgetIntentsExtension.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | 10 | 16 | 22 | 23 | 24 | 30 | 36 | 37 | 38 | 39 | 40 | 46 | 47 | 59 | 61 | 67 | 68 | 69 | 70 | 78 | 80 | 86 | 87 | 88 | 89 | 91 | 92 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Controllers/GroupController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GroupController.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 5/26/21. 6 | // 7 | 8 | import CoreData 9 | import SwiftUI 10 | 11 | class GroupController: NSObject, ObservableObject { 12 | 13 | static let sortDescriptors = [ 14 | NSSortDescriptor(keyPath: \TrackGroup.index, ascending: true), 15 | // For consistency when there are index collisions with iCloud sync 16 | NSSortDescriptor(keyPath: \TrackGroup.name, ascending: true), 17 | ] 18 | 19 | var fetchedResultsController: NSFetchedResultsController 20 | var trackController: TrackController? 21 | var animateNextChange = false 22 | 23 | init(preview: Bool = false) { 24 | let context = (preview ? PersistenceController.preview : PersistenceController.shared).container.viewContext 25 | 26 | let fetchRequest: NSFetchRequest = TrackGroup.fetchRequest() 27 | fetchRequest.sortDescriptors = Self.sortDescriptors 28 | 29 | fetchedResultsController = NSFetchedResultsController( 30 | fetchRequest: fetchRequest, 31 | managedObjectContext: context, 32 | sectionNameKeyPath: nil, 33 | cacheName: nil) 34 | 35 | super.init() 36 | 37 | fetchedResultsController.delegate = self 38 | 39 | do { 40 | try fetchedResultsController.performFetch() 41 | } catch { 42 | NSLog("Error performing TrackGroups fetch: \(error)") 43 | } 44 | } 45 | } 46 | 47 | extension GroupController: NSFetchedResultsControllerDelegate { 48 | func controllerWillChangeContent(_ controller: NSFetchedResultsController) { 49 | // TODO: This is weird 50 | // I feel like I remember having some bugginess when having this in a 51 | // withAnimation all the time, so I did this to be safe. To be fair, 52 | // that would've also been when I was using the awful index collision 53 | // resolution below, so those could've been related. 54 | if animateNextChange { 55 | withAnimation { 56 | objectWillChange.send() 57 | } 58 | animateNextChange = false 59 | } else { 60 | objectWillChange.send() 61 | } 62 | } 63 | /* 64 | func controller(_ controller: NSFetchedResultsController, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { 65 | guard [.delete, .insert].contains(type) else { return } 66 | 67 | var changed = false 68 | 69 | // Update indices 70 | fetchedResultsController.fetchedObjects?.enumerated() 71 | .map({ (Int16($0), $1) }) 72 | .filter({ $0 != $1.index }) 73 | .forEach { index, item in 74 | item.index = index 75 | changed = true 76 | } 77 | 78 | if changed { 79 | trackController?.scheduleSave() 80 | } 81 | trackController?.saveIfScheduled() 82 | } 83 | */ 84 | } 85 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Controllers/Persistence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Persistence.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 2/19/21. 6 | // 7 | 8 | import SwiftUI 9 | import CoreData 10 | import SwiftDate 11 | 12 | class PersistenceController { 13 | 14 | //MARK: Static instances 15 | 16 | static let shared = PersistenceController() 17 | 18 | static var preview: PersistenceController = { 19 | let result = PersistenceController(inMemory: true) 20 | let viewContext = result.container.viewContext 21 | 22 | let group = TrackGroup(name: "Test group", index: 0, context: viewContext) 23 | result.previewGroup = group 24 | 25 | let dateString = TrackController.iso8601.string(from: Date() - 5.days) 26 | for i: Int16 in 0..<6 { 27 | let track = Track( 28 | name: String(UUID().uuidString.dropLast(28)), 29 | multiple: 1...2 ~= i, 30 | reversed: i == 2, 31 | startDate: dateString, 32 | index: i, 33 | isArchived: i == 5, 34 | context: viewContext 35 | ) 36 | 37 | if i == 1 { 38 | for day in 0..<5 { 39 | let count = Int16.random(in: 0..<4) 40 | if count > 0 { 41 | let tick = Tick(track: track, dayOffset: Int16(day), context: viewContext) 42 | tick.count = count 43 | } 44 | } 45 | } else { 46 | for day in 0..<5 where Bool.random() { 47 | Tick(track: track, dayOffset: Int16(day)) 48 | } 49 | track.groups = [group] 50 | } 51 | } 52 | result.save() 53 | return result 54 | }() 55 | 56 | //MARK: Demo 57 | 58 | #if DEBUG 59 | func loadDemo() -> PersistenceController { 60 | let viewContext = container.viewContext 61 | 62 | // Only create demo data if there is not already data 63 | let fetchRequest: NSFetchRequest = Track.fetchRequest() 64 | fetchRequest.fetchLimit = 1 65 | guard (try? viewContext.fetch(fetchRequest))?.isEmpty ?? false else { return self } 66 | print("Loading demo...") 67 | 68 | let startDateOffset = 14 69 | let dateString = TrackController.iso8601.string(from: Date() - startDateOffset.days) 70 | 71 | // Demo Groups 72 | 73 | // Unlock the groups in-app-purchase 74 | UserDefaults.standard.set(true, forKey: StoreController.Products.groups.rawValue) 75 | let daily = TrackGroup(name: "Daily", index: 0, context: viewContext) 76 | let occasional = TrackGroup(name: "Occasional", index: 1, context: viewContext) 77 | let group3 = TrackGroup(name: "Group 3", index: 2, context: viewContext) 78 | 79 | UserDefaults.standard.set(false, forKey: Defaults.showAllTracks.rawValue) 80 | 81 | // Daily Tracks 82 | 83 | let dailyTracks = [ 84 | [true, true, true, false, true, true, true, true, true, true, false, true, false, true, true, true, true, false, false, false, false, true, true, false], 85 | [false, true, true, true, true, true, true, true, true, true, true, false, true, false, true, true, true, false, true, true, true, true, true, false], 86 | [true, true, true, false, true, false, false, true, false, true, false, true, false, true, false].map { !$0 }, // This one is reversed 87 | [true, true, true, true, true, false, true, true, false, true, false, true, true, true, true, true, false, true, true, true, true, true, true, true] 88 | ] 89 | 90 | for (i, ticks) in dailyTracks.enumerated() { 91 | let track = Track( 92 | name: String(UUID().uuidString.dropLast(28)), 93 | multiple: i > 0, 94 | reversed: i == 2, 95 | startDate: dateString, 96 | index: Int16(i), 97 | context: viewContext) 98 | PresetTracks[0].tracks[i].save(to: track) 99 | track.startDate = dateString 100 | track.addToGroups(daily) 101 | 102 | if i == 0 { 103 | track.addToGroups(group3) 104 | } 105 | 106 | for (j, tick) in ticks.enumerated() where tick { 107 | Tick(track: track, dayOffset: Int16(startDateOffset - j), context: viewContext) 108 | } 109 | } 110 | 111 | // Disabled Track 112 | 113 | let disabledTrack = Track( 114 | name: String(UUID().uuidString.dropLast(28)), 115 | multiple: false, 116 | reversed: false, 117 | startDate: dateString, 118 | index: 4, 119 | context: viewContext) 120 | PresetTracks[0].tracks[5].save(to: disabledTrack) 121 | disabledTrack.enabled = false 122 | 123 | // Occasional Tracks 124 | 125 | let occasionalTracks = [ 126 | (0, [1, 8, 15, 22, 29]), 127 | (3, [0, 2, 3, 5, 7, 9, 10, 12, 14, 16, 17, 19, 21, 23, 24, 26, 28]), 128 | (1, [7, 14, 21, 28]), 129 | (4, [14]) 130 | ] 131 | 132 | for (i, (index, ticks)) in occasionalTracks.enumerated() { 133 | let track = Track( 134 | name: "", 135 | startDate: dateString, 136 | index: Int16(i + dailyTracks.count), 137 | context: viewContext) 138 | PresetTracks[1].tracks[index].save(to: track) 139 | track.startDate = dateString 140 | track.addToGroups(occasional) 141 | 142 | if index == 3 { 143 | let reference = PresetTracks[1].tracks[2] 144 | track.name = reference.name 145 | track.systemImage = reference.systemImage 146 | } 147 | 148 | if index > 2 { 149 | track.addToGroups(group3) 150 | } 151 | 152 | for tick in ticks { 153 | Tick(track: track, dayOffset: Int16(startDateOffset - tick), context: viewContext) 154 | } 155 | } 156 | 157 | save() 158 | return self 159 | } 160 | #endif 161 | 162 | //MARK: Properties 163 | 164 | let container: NSPersistentCloudKitContainer 165 | 166 | // Preview content 167 | var previewGroup: TrackGroup? 168 | 169 | init(inMemory: Bool = false) { 170 | let container = NSPersistentCloudKitContainer(name: "Tickmate") 171 | 172 | container.viewContext.automaticallyMergesChangesFromParent = true 173 | 174 | if inMemory { 175 | container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") 176 | container.loadPersistentStores { storeDescription, error in 177 | if error != nil { 178 | fatalError() 179 | } 180 | } 181 | } else { 182 | container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy 183 | 184 | let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID)!.appendingPathComponent("Tickmate.sqlite") 185 | 186 | let oldURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?.appendingPathComponent("Tickmate.sqlite") 187 | UserDefaults.standard.register(defaults: [Defaults.appGroupDatabaseMigration.rawValue: false]) 188 | var needsMigration = !UserDefaults.standard.bool(forKey: Defaults.appGroupDatabaseMigration.rawValue) 189 | if needsMigration, 190 | let oldURL = oldURL, 191 | !FileManager.default.fileExists(atPath: oldURL.path) { 192 | print("New app install. No persistent store migration required.") 193 | UserDefaults.standard.set(true, forKey: Defaults.appGroupDatabaseMigration.rawValue) 194 | needsMigration = false 195 | } 196 | 197 | // Load the current persistent store 198 | if !needsMigration { 199 | container.persistentStoreDescriptions.first?.url = appGroupURL 200 | } 201 | 202 | container.loadPersistentStores { storeDescription, error in 203 | if let error = error { 204 | fatalError("Unresolved error \(error)") 205 | } 206 | print("Loaded persistent store", storeDescription) 207 | 208 | if needsMigration { 209 | // Perform the migration on the main thread 210 | DispatchQueue.main.async { 211 | do { 212 | let coordinator = container.persistentStoreCoordinator 213 | if let oldURL = oldURL, 214 | let oldStore = coordinator.persistentStore(for: oldURL) { 215 | print("Attemping to migrate persistent store from", oldURL, "to", appGroupURL) 216 | try coordinator.migratePersistentStore(oldStore, to: appGroupURL, options: nil, withType: NSSQLiteStoreType) 217 | } 218 | 219 | print("Migration complete.") 220 | UserDefaults.standard.set(true, forKey: Defaults.appGroupDatabaseMigration.rawValue) 221 | } catch { 222 | NSLog("Unable to migrate persistent store: \(error)") 223 | } 224 | } 225 | } 226 | } 227 | } 228 | self.container = container 229 | } 230 | 231 | //MARK: Saving 232 | 233 | /// Saves the container's viewContext if there are changes. 234 | func save() { 235 | PersistenceController.save(context: container.viewContext) 236 | } 237 | 238 | /// Saves the given context if there are changes. 239 | /// - Parameter context: The Core Data context to save. 240 | static func save(context moc: NSManagedObjectContext) { 241 | guard moc.hasChanges else { return } 242 | do { 243 | try moc.save() 244 | } catch { 245 | let nsError = error as NSError 246 | NSLog("Error saving context: \(nsError), \(nsError.userInfo)") 247 | } 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Controllers/StoreController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoreController.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 6/1/21. 6 | // 7 | 8 | import SwiftUI 9 | import StoreKit 10 | 11 | class StoreController: NSObject, ObservableObject { 12 | 13 | // For some reason, using an @AppStorage property on ContentView for 14 | // groups would cause lag when changing page. Even if the property wasn't 15 | // read dirctly, but instead updated a @State property with an .onChange, 16 | // it would still cause the lag, even if that .onChange was never called. 17 | @Published private(set) var groupsUnlocked: Bool 18 | 19 | // TODO: Remove this 20 | // Probably reword this whole system tbh 21 | @Published private(set) var products = [SKProduct]() 22 | @Published private(set) var groupsProduct: SKProduct? 23 | @Published private(set) var isGroupsProductAvailable: Bool = true 24 | 25 | @Published private(set) var purchased = Set() 26 | @Published private(set) var purchasing = Set() 27 | @Published var restored: AlertItem? 28 | 29 | private var isRestoringPurchases = false 30 | 31 | var isAuthorizedForPayments: Bool { 32 | SKPaymentQueue.canMakePayments() 33 | } 34 | 35 | override init() { 36 | groupsUnlocked = UserDefaults.standard.bool(forKey: Products.groups.rawValue) 37 | super.init() 38 | SKPaymentQueue.default().add(self) 39 | } 40 | 41 | var priceFormatter: NumberFormatter = { 42 | let formatter = NumberFormatter() 43 | formatter.formatterBehavior = .behavior10_4 44 | formatter.numberStyle = .currency 45 | return formatter 46 | }() 47 | 48 | func fetchProducts() { 49 | let request = SKProductsRequest(productIdentifiers: Set(Products.allValues)) 50 | request.delegate = self 51 | request.start() 52 | } 53 | 54 | func purchase(_ product: SKProduct) { 55 | guard isAuthorizedForPayments else { return } 56 | 57 | let payment = SKPayment(product: product) 58 | SKPaymentQueue.default().add(payment) 59 | } 60 | 61 | func restorePurchases() { 62 | isRestoringPurchases = true 63 | SKPaymentQueue.default().restoreCompletedTransactions() 64 | } 65 | 66 | #if DEBUG 67 | func removePurchased(id: String) { 68 | UserDefaults.standard.set(false, forKey: id) 69 | purchased.remove(id) 70 | if id == StoreController.Products.groups.rawValue { 71 | groupsUnlocked = false 72 | } 73 | } 74 | #endif 75 | 76 | //MARK: Products 77 | 78 | enum Products: String, CaseIterable { 79 | case groups = "vc.isv.Tickmate.groups" 80 | 81 | static var allValues: [String] { 82 | allCases.map { $0.rawValue } 83 | } 84 | } 85 | 86 | } 87 | 88 | //MARK: Products Request Delegate 89 | 90 | extension StoreController: SKProductsRequestDelegate { 91 | func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { 92 | if response.invalidProductIdentifiers.contains(Products.groups.rawValue) { 93 | DispatchQueue.main.async { 94 | self.isGroupsProductAvailable = false 95 | } 96 | return 97 | } 98 | 99 | if !response.invalidProductIdentifiers.isEmpty { 100 | NSLog("Invalid product identifiers found: \(response.invalidProductIdentifiers)") 101 | } 102 | 103 | DispatchQueue.main.async { 104 | if let locale = response.products.first?.priceLocale { 105 | self.priceFormatter.locale = locale 106 | } 107 | withAnimation { 108 | response.products 109 | .map { $0.productIdentifier } 110 | .filter { UserDefaults.standard.bool(forKey: $0) } 111 | .forEach { self.purchased.insert($0) } 112 | self.products = response.products 113 | self.groupsProduct = response.products.first { product in 114 | product.productIdentifier == Products.groups.rawValue 115 | } 116 | } 117 | } 118 | } 119 | } 120 | 121 | //MARK: Payment Transaction Observer 122 | 123 | extension StoreController: SKPaymentTransactionObserver { 124 | func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { 125 | transactions.forEach { transaction in 126 | let productID = transaction.payment.productIdentifier 127 | 128 | switch transaction.transactionState { 129 | case .purchasing: 130 | withAnimation { 131 | _ = purchasing.insert(productID) 132 | } 133 | case .purchased, .restored: 134 | print("Purchased \(productID)!") 135 | UserDefaults.standard.set(true, forKey: productID) 136 | withAnimation { 137 | purchasing.remove(productID) 138 | purchased.insert(productID) 139 | if productID == StoreController.Products.groups.rawValue { 140 | groupsUnlocked = true 141 | } 142 | } 143 | 144 | queue.finishTransaction(transaction) 145 | case .failed, .deferred: 146 | purchasing.remove(productID) 147 | if let error = transaction.error { 148 | print("Purchase of \(productID) failed: \(error)") 149 | } else { 150 | print("Purchase of \(productID) failed.") 151 | } 152 | 153 | queue.finishTransaction(transaction) 154 | @unknown default: 155 | break 156 | } 157 | } 158 | 159 | if isRestoringPurchases { 160 | let restoredProducts = transactions 161 | .filter { $0.transactionState == .restored } 162 | .compactMap { transaction in products.first(where: { $0.productIdentifier == transaction.payment.productIdentifier }) } 163 | if !restoredProducts.isEmpty { 164 | let alertBody = restoredProducts 165 | .map { $0.localizedTitle } 166 | .joined(separator: ", ") 167 | restored = AlertItem(title: "Purchases restored!", message: alertBody) 168 | isRestoringPurchases = false 169 | } 170 | } 171 | } 172 | 173 | func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) { 174 | // If there were purchases that were restored, isRestoringPurchases, 175 | // would be set to false in paymentQueue(_:, updatedTransactions:), so 176 | // if it's still true here, that means there were no purchases to restore. 177 | if isRestoringPurchases { 178 | restored = AlertItem(title: "No purchases to restore") 179 | isRestoringPurchases = false 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Controllers/TickController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TickController.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 2/21/21. 6 | // 7 | 8 | import CoreData 9 | import CloudKit 10 | import SwiftDate 11 | 12 | class TickController: NSObject, ObservableObject { 13 | 14 | //MARK: Properties 15 | 16 | let track: Track 17 | @Published var ticks: [Tick?] = [] 18 | /// The tick count for a given day as fetched with `loadCKTicks` for new days that aren't already in `ticks`. 19 | @Published var ckTicks: [Int16?]? 20 | private var fetchedResultsController: NSFetchedResultsController 21 | weak var trackController: TrackController? 22 | var todayOffset: Int? 23 | 24 | private var preview: Bool 25 | private var observeChanges: Bool 26 | 27 | init(track: Track, trackController: TrackController, observeChanges: Bool = true, preview: Bool) { 28 | self.track = track 29 | self.trackController = trackController 30 | self.preview = preview 31 | self.observeChanges = observeChanges 32 | 33 | let moc = track.managedObjectContext ?? (preview ? PersistenceController.preview : PersistenceController.shared).container.viewContext 34 | 35 | let fetchRequest: NSFetchRequest = Tick.fetchRequest() 36 | fetchRequest.predicate = NSPredicate(format: "track == %@", track) 37 | fetchRequest.sortDescriptors = [ 38 | NSSortDescriptor(keyPath: \Tick.dayOffset, ascending: false), 39 | NSSortDescriptor(keyPath: \Tick.modified, ascending: false) 40 | ] 41 | 42 | fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, 43 | managedObjectContext: moc, 44 | sectionNameKeyPath: nil, 45 | cacheName: nil) 46 | 47 | super.init() 48 | 49 | if observeChanges { 50 | fetchedResultsController.delegate = self 51 | } 52 | 53 | do { 54 | try fetchedResultsController.performFetch() 55 | } catch { 56 | NSLog("Error performing Ticks fetch for \(track.name ?? "track"): \(error)") 57 | } 58 | 59 | loadTicks() 60 | } 61 | 62 | func setTodayOffset() -> Int? { 63 | guard let startDateString = track.startDate, 64 | let startDate = DateInRegion(startDateString, region: .current)?.dateTruncated(at: [.hour, .minute, .second]), 65 | let today = trackController?.date.in(region: .current).dateTruncated(at: [.hour, .minute, .second]), 66 | // Subtract 2 hours here to overestimate the offset to account for for any daylight savings time changes. 67 | // Because we're truncating at the number of days, having one or two hours extra won't increase 68 | // the day offset, but having one hour less because of DST would incorrectly offset the day. 69 | let todayOffset = (today - (startDate - 2.hours)).toUnit(.day) else { return nil } 70 | self.todayOffset = todayOffset 71 | return todayOffset 72 | } 73 | 74 | func loadTicks() { 75 | guard let allTicks = fetchedResultsController.fetchedObjects, 76 | let todayOffset = setTodayOffset() else { return } 77 | 78 | var ticks = [Tick?]() 79 | var i = 0 80 | var changesToSave = false 81 | 82 | for day in 0..<365 { 83 | let offsetDay = todayOffset - day 84 | while i < allTicks.count, 85 | allTicks[i].dayOffset > offsetDay { 86 | i += 1 87 | } 88 | 89 | guard i < allTicks.count else { break } 90 | 91 | if allTicks[i].dayOffset == offsetDay { 92 | ticks.append(allTicks[i]) 93 | i += 1 94 | // Check for duplicates 95 | // Duplicates could come from multiple devices 96 | // ticking the same day before the CloudKit sync. 97 | while i < allTicks.count, 98 | allTicks[i].dayOffset == offsetDay { 99 | let tick = allTicks[i] 100 | // Delete tick with same dayOffset. Because the FRC is sorted 101 | // by dayOffset then descending modified date, the newest 102 | // Tick should be first and we can delete any after it. 103 | print("Duplicate found:", tick) 104 | // Tag it as a duplicate so the FRC doesn't 105 | // remove its day from the ticks array. 106 | tick.duplicate = true 107 | track.managedObjectContext?.delete(tick) 108 | changesToSave = true 109 | i += 1 110 | } 111 | } else { 112 | ticks.append(nil) 113 | } 114 | } 115 | 116 | // Since the save function already checks the context's hasChanges 117 | // property, this check probably doesn't need to be here, but I don't 118 | // want to be saving more than necissary, and this can't hurt. 119 | if changesToSave { 120 | save() 121 | } 122 | 123 | self.ticks = ticks 124 | } 125 | 126 | /// Fetch `CKRecord`s for the `Tick`s for this `Track` and save them to `ckTicks`. 127 | /// 128 | /// Enters the tick count for each day into `ckTicks`. 129 | /// Only works when `observeChanges` is `false`, such as 130 | /// while the app runs in the background or in an extension, like the widget extension. 131 | /// - Parameter completion: runs on the main thread after the fetched ticks have been assigned to `ckTicks`. 132 | func loadCKTicks(completion: @escaping () -> Void = {}) { 133 | print("loadCKTicks", track.name ?? "") 134 | let container = (preview ? PersistenceController.preview : PersistenceController.shared).container 135 | guard let trackID = container.recordID(for: track.objectID)?.recordName, 136 | let todayOffset = setTodayOffset(), 137 | // Don't load CloudKit Ticks unless fetching for an app extension 138 | !observeChanges else { return completion() } 139 | 140 | let predicate = NSPredicate(format: "CD_track == %@", trackID) 141 | let query = CKQuery(recordType: "CD_Tick", predicate: predicate) 142 | query.sortDescriptors = [NSSortDescriptor(key: "CD_dayOffset", ascending: false)] 143 | 144 | let operation = CKQueryOperation(query: query) 145 | operation.desiredKeys = ["CD_dayOffset", "CD_count"] 146 | operation.resultsLimit = 20 147 | 148 | var ckTicks = [Int16?]() 149 | 150 | operation.recordFetchedBlock = { record in 151 | guard let dayOffset = (record["CD_dayOffset"] as? NSNumber)?.intValue else { return } 152 | let day = todayOffset - dayOffset 153 | guard day >= 0 && day < 365 else { return } 154 | 155 | while ckTicks.count <= day { 156 | ckTicks.append(nil) 157 | } 158 | ckTicks[day] = (record["CD_count"] as? NSNumber)?.int16Value ?? 1 159 | } 160 | 161 | operation.queryCompletionBlock = { [weak self] _, error in 162 | if let error = error { 163 | NSLog("\(error)") 164 | } 165 | DispatchQueue.main.async { 166 | self?.ckTicks = ckTicks 167 | print("Done fetching CloudKit Ticks for", self?.track.name ?? "track", ckTicks) 168 | completion() 169 | } 170 | } 171 | 172 | // The identifier needs to be specified for when this runs in an app extension. 173 | CKContainer(identifier: "iCloud.vc.isv.Tickmate").privateCloudDatabase.add(operation) 174 | } 175 | 176 | //MARK: Ticking 177 | 178 | func tickCount(for day: Int) -> Int16 { 179 | // If CloudKit Ticks have been loaded, show those 180 | if let ckTicks = ckTicks { 181 | guard ckTicks.indices.contains(day) else { return 0 } 182 | return ckTicks[day] ?? 0 183 | } 184 | 185 | return getTick(for: day)?.count ?? 0 186 | } 187 | 188 | func getTick(for day: Int) -> Tick? { 189 | guard ticks.indices.contains(day) else { return nil } 190 | return ticks[day] 191 | } 192 | 193 | func ticks(on day: Int) -> Int { 194 | guard let tick = getTick(for: day) else { return 0 } 195 | return Int(tick.count) 196 | } 197 | 198 | func tick(day: Int) { 199 | if let tick = getTick(for: day) { 200 | if track.multiple { 201 | tick.count += 1 202 | tick.modified = Date() 203 | save() 204 | } else { 205 | untick(day: day) 206 | } 207 | } else if let todayOffset = todayOffset { 208 | Tick(track: track, dayOffset: Int16(todayOffset - day)) 209 | save() 210 | } 211 | } 212 | 213 | @discardableResult 214 | func untick(day: Int) -> Bool { 215 | guard let tick = getTick(for: day) else { return false } 216 | if tick.count < 2 { 217 | track.managedObjectContext?.delete(tick) 218 | } else { 219 | tick.count -= 1 220 | tick.modified = Date() 221 | } 222 | save() 223 | return true 224 | } 225 | 226 | //MARK: Private 227 | 228 | private func save() { 229 | trackController?.scheduleSave() 230 | } 231 | 232 | private func day(for tick: Tick) -> Int? { 233 | guard let todayOffset = todayOffset else { return nil } 234 | return todayOffset - Int(tick.dayOffset) 235 | } 236 | 237 | private func insert(at indexPath: IndexPath) { 238 | let tick = fetchedResultsController.object(at: indexPath) 239 | 240 | guard let day = day(for: tick), 241 | day < 365 else { return } 242 | while ticks.count < day + 1 { 243 | ticks.append(nil) 244 | } 245 | ticks[day] = tick 246 | } 247 | 248 | private func delete(_ object: Any, at indexPath: IndexPath) { 249 | guard let tick = object as? Tick, 250 | // Don't remove the day from the list if this 251 | // was just a duplicate that's being deleted. 252 | !tick.duplicate, 253 | let day = day(for: tick), 254 | ticks.indices.contains(day) else { return } 255 | ticks[day] = nil 256 | 257 | //TODO: Check for duplicates 258 | // Don't forget to tag them as duplicates 259 | // before deleting so that this function 260 | // doesn't run recursively on them. 261 | } 262 | 263 | } 264 | 265 | //MARK: Fetched Results Controller Delegate 266 | 267 | extension TickController: NSFetchedResultsControllerDelegate { 268 | func controller(_ controller: NSFetchedResultsController, 269 | didChange anObject: Any, 270 | at indexPath: IndexPath?, 271 | for type: NSFetchedResultsChangeType, 272 | newIndexPath: IndexPath?) { 273 | switch type { 274 | case .insert: 275 | guard let newIndexPath = newIndexPath else { return } 276 | insert(at: newIndexPath) 277 | case .delete: 278 | guard let indexPath = indexPath else { return } 279 | delete(anObject, at: indexPath) 280 | default: 281 | break 282 | } 283 | 284 | // This is done here as opposed to in the tick(day:) 285 | // function so that the timeline will also be 286 | // refreshed on changes fetched from CloudKit. 287 | trackController?.scheduleTimelineRefresh() 288 | } 289 | 290 | /// Returns the number of days from today to the oldest tick 291 | /// A positive number means the oldest tick is that many days ago 292 | /// 293 | /// This function was written by Trae using Claude-3.5-Sonnet 294 | func oldestTickDate() -> Int? { 295 | guard let allTicks = fetchedResultsController.fetchedObjects, 296 | let lastTick = allTicks.last, 297 | let todayOffset = todayOffset 298 | else { return nil } 299 | 300 | // Since ticks are sorted by dayOffset descending, the last tick has the smallest offset 301 | // Convert from dayOffset (days since start) to days from today 302 | return todayOffset - Int(lastTick.dayOffset) 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Controllers/ViewControllerContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewControllerContainer.swift 3 | // Tickmate 4 | // 5 | // Created by Isaac Lyons on 3/12/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | class ViewControllerContainer: NSObject, ObservableObject, UIAdaptivePresentationControllerDelegate, UITextFieldDelegate { 11 | 12 | @Published var editMode = EditMode.inactive 13 | 14 | weak var vc: UIViewController? = nil 15 | weak var textField: UITextField? 16 | 17 | var shouldReturn: (() -> Bool)? 18 | var textFieldShouldEnableEditMode = false 19 | 20 | func deactivateEditMode() { 21 | // Because editMode is @Published, if a View edits it in an Introspect function, that will 22 | // cause the view to redraw, causing the function to be called again, creating and infinite 23 | // cycle. Perform this check so it only publishes changes if it actually needs to change. 24 | if editMode.isEditing { 25 | editMode = .inactive 26 | } 27 | } 28 | 29 | func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { 30 | !editMode.isEditing 31 | } 32 | 33 | func textFieldShouldReturn(_ textField: UITextField) -> Bool { 34 | shouldReturn?() ?? true 35 | } 36 | 37 | func textFieldDidBeginEditing(_ textField: UITextField) { 38 | if textFieldShouldEnableEditMode { 39 | editMode = .active 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | LSRequiresIPhoneOS 22 | 23 | NSUserActivityTypes 24 | 25 | ConfigurationIntent 26 | 27 | UIApplicationSceneManifest 28 | 29 | UIApplicationSupportsMultipleScenes 30 | 31 | 32 | UIApplicationSupportsIndirectInputEvents 33 | 34 | UIBackgroundModes 35 | 36 | remote-notification 37 | 38 | UILaunchStoryboardName 39 | Launch Screen 40 | UIRequiredDeviceCapabilities 41 | 42 | armv7 43 | 44 | UISupportedInterfaceOrientations 45 | 46 | UIInterfaceOrientationPortrait 47 | UIInterfaceOrientationLandscapeLeft 48 | UIInterfaceOrientationLandscapeRight 49 | 50 | UISupportedInterfaceOrientations~ipad 51 | 52 | UIInterfaceOrientationPortrait 53 | UIInterfaceOrientationPortraitUpsideDown 54 | UIInterfaceOrientationLandscapeLeft 55 | UIInterfaceOrientationLandscapeRight 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Model/Binding+onChange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Binding+onChange.swift 3 | // Tickmate 4 | // 5 | // Created by Isaac Lyons on 5/20/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Binding { 11 | func onChange(_ handler: @escaping (Value) -> Void) -> Binding { 12 | Binding( 13 | get: { wrappedValue }, 14 | set: { newValue in 15 | wrappedValue = newValue 16 | handler(newValue) 17 | } 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Model/Defaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Defaults.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 3/9/21. 6 | // 7 | 8 | import Foundation 9 | 10 | let groupID = "group.vc.isv.Tickmate" 11 | 12 | enum Defaults: String { 13 | case customDayStart // Bool App Group 14 | case customDayStartMinutes // Int App Group 15 | case weekSeparatorSpaces // Bool 16 | case weekSeparatorLines // Bool 17 | case weekStartDay // Int App Group 18 | case relativeDates // Bool 19 | case onboardingComplete // Bool 20 | case showAllTracks // Bool 21 | case showUngroupedTracks // Bool 22 | case groupPage // Int 23 | case appGroupDatabaseMigration // Bool 24 | case userDefaultsMigration // Bool App Group 25 | case lastUpdateTime // String App Group 26 | case todayAtTop // Bool App Group 27 | case todayLock // Bool App Group 28 | } 29 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Model/Tickmate+Convenience.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tickmate+Convenience.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 2/21/21. 6 | // 7 | 8 | import CoreData 9 | import SwiftUI 10 | 11 | extension TrackGroup { 12 | @discardableResult 13 | convenience init(name: String? = nil, index: Int16, context moc: NSManagedObjectContext) { 14 | self.init(context: moc) 15 | self.name = name 16 | self.index = index 17 | } 18 | } 19 | 20 | extension Track { 21 | @discardableResult 22 | convenience init( 23 | name: String, 24 | color: Int32? = nil, 25 | multiple: Bool = false, 26 | reversed: Bool = false, 27 | startDate: String, 28 | systemImage: String? = nil, 29 | index: Int16, 30 | isArchived: Bool = false, 31 | context moc: NSManagedObjectContext 32 | ) { 33 | self.init(context: moc) 34 | self.name = name 35 | self.color = color ?? Int32(Color(hue: Double.random(in: 0...1), saturation: 1, brightness: 1).rgb) 36 | self.multiple = multiple 37 | self.reversed = reversed 38 | self.startDate = startDate 39 | self.systemImage = systemImage ?? SymbolsList.randomElement() 40 | self.index = index 41 | self.isArchived = isArchived 42 | } 43 | 44 | var lightText: Bool { 45 | let r = Double((color & 0xff0000) >> 16) 46 | let g = Double((color & 0x00ff00) >> 8) 47 | let b = Double((color & 0x0000ff)) 48 | let luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255 49 | return luma < 2/3 50 | } 51 | 52 | /// Archives the Track and removes it from all groups. 53 | func archive() { 54 | isArchived = true 55 | // ContentView only displays Groups that aren't empty, but it would take 56 | // a subquery to first filter out archived Tracks, but SwiftUI then 57 | // won't dynamically respond to those changes, so it's easiest to just 58 | // remove archived tracks from any groups. 59 | self.groups = Set() as NSSet 60 | } 61 | } 62 | 63 | extension Tick { 64 | @discardableResult 65 | convenience init(track: Track, dayOffset: Int16, context moc: NSManagedObjectContext) { 66 | self.init(context: moc) 67 | self.track = track 68 | self.dayOffset = dayOffset 69 | self.count = 1 70 | self.modified = Date() 71 | } 72 | 73 | @discardableResult 74 | convenience init?(track: Track, dayOffset: Int16) { 75 | guard let moc = track.managedObjectContext else { return nil } 76 | self.init(track: track, dayOffset: dayOffset, context: moc) 77 | } 78 | } 79 | 80 | extension NSMutableSet { 81 | func toggle(_ member: Element) { 82 | if contains(member) { 83 | remove(member) 84 | } else { 85 | add(member) 86 | } 87 | } 88 | } 89 | 90 | extension Set { 91 | mutating func toggle(_ member: Element) { 92 | if contains(member) { 93 | remove(member) 94 | } else { 95 | insert(member) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Model/Tickmate+Wrapping.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tickmate+Wrapping.swift 3 | // Tickmate 4 | // 5 | // Created by Isaac Lyons on 5/14/21. 6 | // 7 | 8 | import Foundation 9 | 10 | extension TrackGroup { 11 | var wrappedName: String { 12 | get { name ?? "" } 13 | set { name = newValue } 14 | } 15 | 16 | var displayName: String { 17 | name ??? "New Group" 18 | } 19 | } 20 | 21 | extension Bool { 22 | var int: Int { 23 | self ? 1 : 0 24 | } 25 | } 26 | 27 | infix operator ???: NilCoalescingPrecedence 28 | 29 | func ??? (optional: C?, defaultValue: @autoclosure () throws -> C) rethrows -> C { 30 | if let value = optional, 31 | !value.isEmpty { 32 | return value 33 | } 34 | return try defaultValue() 35 | } 36 | 37 | func ??? (optional: C?, defaultValue: @autoclosure () throws -> C?) rethrows -> C? { 38 | if let value = optional, 39 | !value.isEmpty { 40 | return value 41 | } 42 | return try defaultValue() 43 | } 44 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Model/Tickmate.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | Tickmate.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Model/Tickmate.xcdatamodeld/Tickmate.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Model/TrackGroups.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrackGroups.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 5/31/21. 6 | // 7 | 8 | import Foundation 9 | 10 | class TrackGroups: ObservableObject { 11 | @Published private(set) var set = Set() 12 | @Published private(set) var array = [TrackGroup]() 13 | 14 | var name: String { 15 | array.map { $0.displayName }.joined(separator: ", ") 16 | } 17 | 18 | func load(_ track: Track) { 19 | guard !track.isArchived else { 20 | // Archived tracks should not have any groups. 21 | // Remove any that might be there from sync conflicts. 22 | set.removeAll() 23 | array.removeAll() 24 | save(to: track) 25 | return 26 | } 27 | 28 | guard let groups = track.groups as? Set else { return } 29 | set = groups 30 | array = groups.sorted(by: { $0.index < $1.index }) 31 | } 32 | 33 | func save(to track: Track) { 34 | track.groups = set as NSSet 35 | } 36 | 37 | func toggle(_ group: TrackGroup, in track: Track) { 38 | if set.contains(group) { 39 | set.remove(group) 40 | array.removeAll(where: { $0 == group }) 41 | } else { 42 | set.insert(group) 43 | array.insert(group, at: array.firstIndex(where: { $0.index > group.index }) ?? array.count) 44 | } 45 | } 46 | 47 | func contains(_ group: TrackGroup) -> Bool { 48 | set.contains(group) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Model/TrackRepresentation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrackRepresentation.swift 3 | // Tickmate 4 | // 5 | // Created by Isaac Lyons on 2/23/21. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftDate 10 | 11 | struct TrackRepresentation: Equatable, Hashable { 12 | var name: String 13 | var color: Color 14 | var multiple: Bool 15 | var reversed: Bool 16 | var systemImage: String? 17 | var startDate: Date 18 | 19 | init(name: String = "", color: Color = .black, multiple: Bool = false, reversed: Bool = false, systemImage: String? = nil, startDate: Date = Date()) { 20 | self.name = name 21 | self.color = color 22 | self.multiple = multiple 23 | self.reversed = reversed 24 | self.systemImage = systemImage 25 | self.startDate = startDate 26 | } 27 | 28 | init(name: String = "", red: Int, green: Int, blue: Int, multiple: Bool = false, reversed: Bool = false, systemImage: String? = nil) { 29 | self.name = name 30 | self.color = .init(red: Double(red)/255, green: Double(green)/255, blue: Double(blue)/255) 31 | self.multiple = multiple 32 | self.reversed = reversed 33 | self.systemImage = systemImage 34 | self.startDate = Date() 35 | } 36 | 37 | var lightText: Bool { 38 | guard let components = color.cgColor?.components, 39 | components.count >= 3 else { return true } 40 | 41 | let r = components[0] 42 | let g = components[1] 43 | let b = components[2] 44 | let luma = (0.299 * r + 0.587 * g + 0.114 * b) 45 | return luma < 2/3 46 | } 47 | 48 | mutating func load(track: Track) { 49 | name = track.name == "New Track" ? "" : track.name ?? "" 50 | multiple = track.multiple 51 | reversed = track.reversed 52 | if let startDateString = track.startDate, 53 | let startDate = TrackController.iso8601.date(from: startDateString) { 54 | self.startDate = startDate 55 | } 56 | 57 | if let trackImage = track.systemImage { 58 | systemImage = trackImage 59 | } else { 60 | systemImage = SymbolsList.randomElement() 61 | } 62 | 63 | color = Color(rgb: Int(track.color)) 64 | } 65 | 66 | func save(to track: Track) { 67 | // Avoid writing duplicate information as doing 68 | // so will mark the context as modified even 69 | // though it wouldn't be. 70 | guard self != track else { return } 71 | track.name = name 72 | track.multiple = multiple 73 | track.reversed = reversed 74 | track.systemImage = systemImage 75 | track.color = Int32(color.rgb) 76 | track.startDate = TrackController.iso8601.string(from: startDate) 77 | } 78 | 79 | static func == (rep: TrackRepresentation, track: Track) -> Bool { 80 | return 81 | rep.name == track.name && 82 | rep.multiple == track.multiple && 83 | rep.reversed == track.reversed && 84 | rep.systemImage == track.systemImage && 85 | Int32(rep.color.rgb) == track.color && 86 | TrackController.iso8601.string(from: rep.startDate) == track.startDate 87 | } 88 | 89 | static func == (track: Track, rep: TrackRepresentation) -> Bool { 90 | return rep == track 91 | } 92 | 93 | static func != (rep: TrackRepresentation, track: Track) -> Bool { 94 | return !(rep == track) 95 | } 96 | 97 | static func != (track: Track, rep: TrackRepresentation) -> Bool { 98 | return !(rep == track) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/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 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "images" : [ 7 | { 8 | "idiom" : "ipad", 9 | "scale" : "1x", 10 | "filename" : "icon-40.png", 11 | "size" : "40x40" 12 | }, 13 | { 14 | "filename" : "icon-40@2x.png", 15 | "idiom" : "ipad", 16 | "size" : "40x40", 17 | "scale" : "2x" 18 | }, 19 | { 20 | "scale" : "2x", 21 | "size" : "60x60", 22 | "filename" : "icon-60@2x.png", 23 | "idiom" : "iphone" 24 | }, 25 | { 26 | "size" : "72x72", 27 | "idiom" : "ipad", 28 | "scale" : "1x", 29 | "filename" : "icon-72.png" 30 | }, 31 | { 32 | "scale" : "2x", 33 | "filename" : "icon-72@2x.png", 34 | "idiom" : "ipad", 35 | "size" : "72x72" 36 | }, 37 | { 38 | "filename" : "icon-76.png", 39 | "idiom" : "ipad", 40 | "scale" : "1x", 41 | "size" : "76x76" 42 | }, 43 | { 44 | "scale" : "2x", 45 | "filename" : "icon-76@2x.png", 46 | "size" : "76x76", 47 | "idiom" : "ipad" 48 | }, 49 | { 50 | "scale" : "1x", 51 | "filename" : "icon-small-50.png", 52 | "size" : "50x50", 53 | "idiom" : "ipad" 54 | }, 55 | { 56 | "idiom" : "ipad", 57 | "filename" : "icon-small-50@2x.png", 58 | "scale" : "2x", 59 | "size" : "50x50" 60 | }, 61 | { 62 | "idiom" : "iphone", 63 | "scale" : "1x", 64 | "size" : "29x29", 65 | "filename" : "icon-small.png" 66 | }, 67 | { 68 | "idiom" : "iphone", 69 | "filename" : "icon-small@2x.png", 70 | "scale" : "2x", 71 | "size" : "29x29" 72 | }, 73 | { 74 | "size" : "57x57", 75 | "filename" : "icon.png", 76 | "idiom" : "iphone", 77 | "scale" : "1x" 78 | }, 79 | { 80 | "filename" : "icon@2x.png", 81 | "idiom" : "iphone", 82 | "scale" : "2x", 83 | "size" : "57x57" 84 | }, 85 | { 86 | "filename" : "icon-small@3x.png", 87 | "idiom" : "iphone", 88 | "scale" : "3x", 89 | "size" : "29x29" 90 | }, 91 | { 92 | "scale" : "3x", 93 | "idiom" : "iphone", 94 | "filename" : "icon-40@3x.png", 95 | "size" : "40x40" 96 | }, 97 | { 98 | "filename" : "icon-60@3x.png", 99 | "scale" : "3x", 100 | "size" : "60x60", 101 | "idiom" : "iphone" 102 | }, 103 | { 104 | "idiom" : "iphone", 105 | "filename" : "icon-40@2x.png", 106 | "size" : "40x40", 107 | "scale" : "2x" 108 | }, 109 | { 110 | "idiom" : "ipad", 111 | "filename" : "icon-small.png", 112 | "size" : "29x29", 113 | "scale" : "1x" 114 | }, 115 | { 116 | "idiom" : "ipad", 117 | "size" : "29x29", 118 | "filename" : "icon-small@2x.png", 119 | "scale" : "2x" 120 | }, 121 | { 122 | "size" : "83.5x83.5", 123 | "idiom" : "ipad", 124 | "scale" : "2x", 125 | "filename" : "icon-83.5@2x.png" 126 | }, 127 | { 128 | "filename" : "notification-icon@2x.png", 129 | "idiom" : "iphone", 130 | "size" : "20x20", 131 | "scale" : "2x" 132 | }, 133 | { 134 | "size" : "20x20", 135 | "idiom" : "iphone", 136 | "scale" : "3x", 137 | "filename" : "notification-icon@3x.png" 138 | }, 139 | { 140 | "filename" : "notification-icon~ipad.png", 141 | "size" : "20x20", 142 | "scale" : "1x", 143 | "idiom" : "ipad" 144 | }, 145 | { 146 | "filename" : "notification-icon~ipad@2x.png", 147 | "size" : "20x20", 148 | "idiom" : "ipad", 149 | "scale" : "2x" 150 | }, 151 | { 152 | "idiom" : "ios-marketing", 153 | "filename" : "ios-marketing.png", 154 | "scale" : "1x", 155 | "size" : "1024x1024" 156 | } 157 | ] 158 | } -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-40.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-72.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-72@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-72@2x.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-76.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-small-50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-small-50.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-small-50@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-small-50@2x.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-small.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-small@2x.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-small@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon-small@3x.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/icon@2x.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/ios-marketing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/ios-marketing.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/notification-icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/notification-icon@2x.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/notification-icon@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/notification-icon@3x.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/notification-icon~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/notification-icon~ipad.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/notification-icon~ipad@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.appiconset/notification-icon~ipad@2x.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/Background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/Background.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Background.png", 5 | "idiom" : "vision", 6 | "scale" : "2x" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.solidimagestack/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "layers" : [ 7 | { 8 | "filename" : "Front.solidimagestacklayer" 9 | }, 10 | { 11 | "filename" : "Middle.solidimagestacklayer" 12 | }, 13 | { 14 | "filename" : "Back.solidimagestacklayer" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Ticks.png", 5 | "idiom" : "vision", 6 | "scale" : "2x" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/Ticks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Content.imageset/Ticks.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.solidimagestack/Front.solidimagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Dividers.png", 5 | "idiom" : "vision", 6 | "scale" : "2x" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Dividers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skjiisa/Tickmate-iOS/4ba89869b55205e4f51fa032610793a5c7d5c206/Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Dividers.png -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Configuration.storekit: -------------------------------------------------------------------------------- 1 | { 2 | "identifier" : "6A8C1B93", 3 | "nonRenewingSubscriptions" : [ 4 | 5 | ], 6 | "products" : [ 7 | { 8 | "displayPrice" : "1.99", 9 | "familyShareable" : false, 10 | "internalID" : "07E2B154", 11 | "localizations" : [ 12 | { 13 | "description" : "Unlock track grouping feature", 14 | "displayName" : "Groups", 15 | "locale" : "en_US" 16 | } 17 | ], 18 | "productID" : "vc.isv.Tickmate.groups", 19 | "referenceName" : "Groups", 20 | "type" : "NonConsumable" 21 | } 22 | ], 23 | "settings" : { 24 | 25 | }, 26 | "subscriptionGroups" : [ 27 | 28 | ], 29 | "version" : { 30 | "major" : 1, 31 | "minor" : 1 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/Launch Screen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/PresetTracks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PresetTracks.swift 3 | // Tickmate 4 | // 5 | // Created by Isaac Lyons on 3/9/21. 6 | // 7 | 8 | import Foundation 9 | 10 | typealias TracksList = [(title: String, tracks: [TrackRepresentation])] 11 | 12 | let PresetTracks: TracksList = [ 13 | ("Daily habits", [ 14 | // Colors based on light-mode versions of Apple adaptable system colors. 15 | // https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/color/ 16 | // The adaptable colors con't be used, though, as they can't be extracted into their RGB values. 17 | .init(name: "Read", red: 255, green: 204, blue: 0, multiple: false, reversed: false, systemImage: "book.closed"), 18 | .init(name: "Exercised", red: 0, green: 122, blue: 255, multiple: false, reversed: false, systemImage: "figure.walk"), 19 | .init(name: "Didn't eat junk food", red: 255, green: 45, blue: 85, multiple: false, reversed: true, systemImage: "trash"), 20 | .init(name: "Walked pet", red: 52, green: 199, blue: 89, multiple: false, reversed: false, systemImage: "hare.fill"), 21 | .init(name: "Took medication", red: 90, green: 200, blue: 250, multiple: false, reversed: false, systemImage: "pills.fill"), 22 | .init(name: "Practiced instrument", red: 88, green: 86, blue: 214, multiple: false, reversed: false, systemImage: "pianokeys"), 23 | .init(name: "Didn't smoke", red: 255, green: 149, blue: 0, multiple: false, reversed: true, systemImage: "flame"), 24 | .init(name: "Symptom occurred", red: 255, green: 59, blue: 48, multiple: true, reversed: false, systemImage: "waveform.path.ecg") 25 | ]), 26 | ("Occasional tasks", [ 27 | .init(name: "Changed towels in bathroom", red: 214, green: 149, blue: 247, multiple: false, reversed: false, systemImage: "map"), 28 | .init(name: "Changed sheets", red: 251, green: 246, blue: 219, multiple: false, reversed: false, systemImage: "bed.double"), 29 | .init(name: "Washed hair", red: 52, green: 199, blue: 89, multiple: false, reversed: false, systemImage: "wand.and.stars.inverse"), 30 | .init(name: "Washed water bottle", red: 89, green: 196, blue: 246, multiple: false, reversed: false, systemImage: "drop.fill"), 31 | .init(name: "Backed up files", red: 255, green: 59, blue: 48, multiple: false, reversed: false, systemImage: "externaldrive.fill.badge.timemachine") 32 | ]) 33 | ] 34 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Resources/SymbolsList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SymbolsList.swift 3 | // Tickmate 4 | // 5 | // Created by Isaac Lyons on 2/24/21. 6 | // 7 | 8 | import Foundation 9 | 10 | let SymbolsList: [String] = [ 11 | "pencil", 12 | "pencil.slash", 13 | "lasso", 14 | "trash", 15 | "trash.fill", 16 | "folder", 17 | "folder.fill", 18 | "paperplane", 19 | "paperplane.fill", 20 | "tray", 21 | "tray.fill", 22 | "internaldrive", 23 | "internaldrive.fill", 24 | "externaldrive.connected.to.line.below", 25 | "externaldrive.connected.to.line.below.fill", 26 | "archivebox", 27 | "archivebox.fill", 28 | "doc", 29 | "doc.fill", 30 | "doc.on.doc", 31 | "doc.on.doc.fill", 32 | "doc.on.clipboard", 33 | "doc.on.clipboard.fill", 34 | "calendar", 35 | "book", 36 | "book.fill", 37 | "newspaper", 38 | "newspaper.fill", 39 | "books.vertical", 40 | "books.vertical.fill", 41 | "book.closed", 42 | "book.closed.fill", 43 | "bookmark", 44 | "bookmark.fill", 45 | "graduationcap", 46 | "graduationcap.fill", 47 | "ticket", 48 | "ticket.fill", 49 | "paperclip", 50 | "personalhotspot", 51 | "person", 52 | "person.fill", 53 | "person.fill.checkmark", 54 | "person.fill.xmark", 55 | "person.fill.questionmark", 56 | "person.and.arrow.left.and.arrow.right", 57 | "person.fill.and.arrow.left.and.arrow.right", 58 | "person.2", 59 | "person.2.fill", 60 | "person.3", 61 | "person.3.fill", 62 | "power", 63 | "globe", 64 | "network", 65 | "sun.min", 66 | "sun.min.fill", 67 | "sun.max", 68 | "sun.max.fill", 69 | "sunrise", 70 | "sunrise.fill", 71 | "sunset", 72 | "sunset.fill", 73 | "sun.dust", 74 | "sun.dust.fill", 75 | "sun.haze", 76 | "sun.haze.fill", 77 | "moon", 78 | "moon.fill", 79 | "sparkles", 80 | "moon.stars", 81 | "moon.stars.fill", 82 | "cloud", 83 | "cloud.fill", 84 | "cloud.drizzle", 85 | "cloud.drizzle.fill", 86 | "cloud.rain", 87 | "cloud.rain.fill", 88 | "cloud.heavyrain", 89 | "cloud.heavyrain.fill", 90 | "cloud.snow", 91 | "cloud.snow.fill", 92 | "cloud.bolt", 93 | "cloud.bolt.fill", 94 | "smoke", 95 | "smoke.fill", 96 | "wind", 97 | "snow", 98 | "aqi.low", 99 | "aqi.medium", 100 | "aqi.high", 101 | "umbrella", 102 | "umbrella.fill", 103 | "flame", 104 | "flame.fill", 105 | "keyboard", 106 | "drop", 107 | "drop.fill", 108 | "play", 109 | "play.fill", 110 | "stop", 111 | "stop.fill", 112 | "shuffle", 113 | "repeat", 114 | "infinity", 115 | "megaphone", 116 | "megaphone.fill", 117 | "speaker", 118 | "speaker.fill", 119 | "speaker.slash", 120 | "speaker.slash.fill", 121 | "music.mic", 122 | "magnifyingglass", 123 | "mic", 124 | "mic.fill", 125 | "mic.slash", 126 | "heart", 127 | "heart.fill", 128 | "bolt.heart", 129 | "bolt.heart.fill", 130 | "flag", 131 | "flag.fill", 132 | "bell", 133 | "bell.fill", 134 | "tag", 135 | "tag.fill", 136 | "bolt", 137 | "bolt.fill", 138 | "bolt.horizontal", 139 | "bolt.horizontal.fill", 140 | "eye", 141 | "eye.fill", 142 | "nose", 143 | "nose.fill", 144 | "mouth", 145 | "mouth.fill", 146 | "flashlight.off.fill", 147 | "flashlight.on.fill", 148 | "camera", 149 | "camera.fill", 150 | "bubble.left", 151 | "bubble.left.fill", 152 | "exclamationmark.bubble", 153 | "exclamationmark.bubble.fill", 154 | "phone", 155 | "phone.fill", 156 | "envelope", 157 | "envelope.fill", 158 | "gear", 159 | "gearshape", 160 | "gearshape.fill", 161 | "gearshape.2", 162 | "gearshape.2.fill", 163 | "signature", 164 | "scissors", 165 | "bag", 166 | "bag.fill", 167 | "cart", 168 | "cart.fill", 169 | "creditcard", 170 | "creditcard.fill", 171 | "wand.and.rays", 172 | "wand.and.rays.inverse", 173 | "wand.and.stars", 174 | "wand.and.stars.inverse", 175 | "crop", 176 | "gyroscope", 177 | "gauge", 178 | "speedometer", 179 | "metronome", 180 | "metronome.fill", 181 | "pianokeys", 182 | "pianokeys.inverse", 183 | "paintbrush", 184 | "paintbrush.fill", 185 | "paintbrush.pointed", 186 | "paintbrush.pointed.fill", 187 | "bandage", 188 | "bandage.fill", 189 | "hammer", 190 | "hammer.fill", 191 | "eyedropper.halffull", 192 | "printer", 193 | "printer.fill", 194 | "case", 195 | "case.fill", 196 | "latch.2.case", 197 | "latch.2.case.fill", 198 | "cross.case", 199 | "cross.case.fill", 200 | "puzzlepiece", 201 | "puzzlepiece.fill", 202 | "house", 203 | "house.fill", 204 | "lock", 205 | "lock.fill", 206 | "key", 207 | "key.fill", 208 | "wifi", 209 | "wifi.slash", 210 | "pin", 211 | "pin.fill", 212 | "mappin", 213 | "mappin.and.ellipse", 214 | "map", 215 | "map.fill", 216 | "tv", 217 | "tv.fill", 218 | "apps.iphone", 219 | "dot.radiowaves.left.and.right", 220 | "car", 221 | "car.fill", 222 | "bolt.car", 223 | "bolt.car.fill", 224 | "car.2", 225 | "car.2.fill", 226 | "bus", 227 | "bus.fill", 228 | "tram", 229 | "tram.fill", 230 | "bicycle", 231 | "bed.double", 232 | "bed.double.fill", 233 | "lungs", 234 | "lungs.fill", 235 | "pills", 236 | "pills.fill", 237 | "hare", 238 | "hare.fill", 239 | "tortoise", 240 | "tortoise.fill", 241 | "ant", 242 | "ant.fill", 243 | "ladybug", 244 | "ladybug.fill", 245 | "leaf", 246 | "leaf.fill", 247 | "film", 248 | "film.fill", 249 | "face.smiling", 250 | "face.smiling.fill", 251 | "crown", 252 | "crown.fill", 253 | "comb", 254 | "comb.fill", 255 | "shield", 256 | "shield.slash", 257 | "shield.fill", 258 | "shield.slash.fill", 259 | "shield.lefthalf.fill", 260 | "shield.lefthalf.fill.slash", 261 | "shield.checkerboard", 262 | "cube", 263 | "cube.fill", 264 | "shippingbox", 265 | "shippingbox.fill", 266 | "clock", 267 | "clock.fill", 268 | "deskclock", 269 | "deskclock.fill", 270 | "alarm", 271 | "alarm.fill", 272 | "stopwatch", 273 | "stopwatch.fill", 274 | "timer", 275 | "gamecontroller", 276 | "gamecontroller.fill", 277 | "paintpalette", 278 | "paintpalette.fill", 279 | "figure.walk", 280 | "ear", 281 | "hearingaid.ear", 282 | "hand.raised", 283 | "hand.raised.fill", 284 | "hand.raised.slash", 285 | "hand.raised.slash.fill", 286 | "hand.thumbsup", 287 | "hand.thumbsup.fill", 288 | "hand.thumbsdown", 289 | "hand.thumbsdown.fill", 290 | "hands.sparkles", 291 | "hands.sparkles.fill", 292 | "waveform.path.ecg", 293 | "waveform.path.ecg.rectangle", 294 | "waveform.path.ecg.rectangle.fill", 295 | "waveform", 296 | "headphones", 297 | "gift", 298 | "gift.fill", 299 | "airplane", 300 | "hourglass", 301 | "hourglass.bottomhalf.fill", 302 | "hourglass.tophalf.fill", 303 | "lightbulb", 304 | "lightbulb.fill", 305 | "list.dash", 306 | "list.bullet", 307 | "list.bullet.indent", 308 | "text.alignleft", 309 | "text.justifyleft" 310 | ] 311 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Supplementary Views/AcknowledgementLink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AcknowledgementLink.swift 3 | // Tickmate 4 | // 5 | // Created by Isaac Lyons on 3/10/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | //MARK: AcknowledgementLink 11 | 12 | struct AcknowledgementLink: View { 13 | 14 | var acknowledgement: Acknowledgement 15 | 16 | @Binding var selection: Acknowledgement? 17 | 18 | init(_ acknowledgement: Acknowledgement, selection: Binding) { 19 | self.acknowledgement = acknowledgement 20 | _selection = selection 21 | } 22 | 23 | var body: some View { 24 | NavigationLink(acknowledgement.name, 25 | destination: AcknowledgementDetail(acknowledgement), 26 | tag: acknowledgement, 27 | selection: $selection) 28 | } 29 | } 30 | 31 | //MARK: AcknowledgementDetail 32 | 33 | struct AcknowledgementDetail: View { 34 | 35 | var acknowledgement: Acknowledgement 36 | 37 | init(_ acknowledgement: Acknowledgement) { 38 | self.acknowledgement = acknowledgement 39 | } 40 | 41 | var body: some View { 42 | List { 43 | if let url = acknowledgement.url { 44 | Section { 45 | Link(destination: url) { 46 | HStack { 47 | Text(acknowledgement.name) 48 | Spacer() 49 | Image(systemName: "arrow.up.right") 50 | } 51 | } 52 | } 53 | } 54 | 55 | Section(header: Text("License")) { 56 | Text(acknowledgement.licenseText) 57 | } 58 | } 59 | .listStyle(InsetGroupedListStyle()) 60 | .navigationTitle(acknowledgement.name) 61 | } 62 | } 63 | 64 | // MARK: Acknowledgement 65 | 66 | struct Acknowledgement: Hashable { 67 | 68 | var name: String 69 | var copyright: String 70 | var link: String? 71 | var license: License 72 | 73 | internal init(name: String, copyright: String, link: String?, license: Acknowledgement.License) { 74 | self.name = name 75 | self.copyright = copyright 76 | self.link = link 77 | self.license = license 78 | } 79 | 80 | enum License { 81 | case mit 82 | case bsd2 83 | } 84 | 85 | var url: URL? { 86 | guard let link = link else { return nil } 87 | return URL(string: link) 88 | } 89 | 90 | var licenseText: String { 91 | switch license { 92 | case .mit: 93 | return """ 94 | Copyright (c) \(copyright) 95 | 96 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 97 | 98 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 99 | 100 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 101 | """ 102 | case .bsd2: 103 | return """ 104 | Copyright (c) \(copyright) 105 | All rights reserved. 106 | 107 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 108 | 109 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 110 | 111 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 112 | 113 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 114 | """ 115 | } 116 | } 117 | } 118 | 119 | //MARK: Preview 120 | 121 | struct AcknowledgementLink_Previews: PreviewProvider { 122 | 123 | static let acknowledgement = Acknowledgement(name: "Library", 124 | copyright: "20XX, Holder", 125 | link: "https://example.com/", 126 | license: .mit) 127 | 128 | static var previews: some View { 129 | NavigationView { 130 | List { 131 | AcknowledgementLink(acknowledgement, selection: .constant(acknowledgement)) 132 | } 133 | .listStyle(InsetGroupedListStyle()) 134 | .navigationTitle("Acknowledgements") 135 | } 136 | 137 | NavigationView { 138 | AcknowledgementDetail(acknowledgement) 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Supplementary Views/AlertItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlertItem.swift 3 | // Tickmate 4 | // 5 | // Created by Isaac Lyons on 6/1/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | class AlertItem: Identifiable { 11 | var title: String 12 | var message: String? 13 | 14 | init(title: String, message: String? = nil) { 15 | self.title = title 16 | self.message = message 17 | } 18 | 19 | var alert: Alert { 20 | if let message = message { 21 | return Alert(title: Text(title), message: Text(message)) 22 | } 23 | 24 | return Alert(title: Text(title)) 25 | } 26 | 27 | static func alert(for alertItem: AlertItem) -> Alert { 28 | alertItem.alert 29 | } 30 | } 31 | 32 | extension View { 33 | func alert(alertItem: Binding) -> some View { 34 | self.alert(item: alertItem) { $0.alert } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Supplementary Views/Animation+Push.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Animation+Push.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 5/19/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Animation { 11 | // Derived by screen recording a standard NavigationLink and going frame- 12 | // by-frame, counting the distance traveled from the edge of the screen. 13 | // This was then plotted and a Bezier curve roughly matched onto it by-hand. 14 | // The coordinates of the control points are the values in the timingCurve. 15 | // Update: Turns out the numbers are simpler than I realized lol. 16 | /// An animation curve matching that of the iOS native navigation push. 17 | static var push: Animation { 18 | .timingCurve(0.25, 1, 0.25, 1, duration: 0.5) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Supplementary Views/Color+RGB.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+RGB.swift 3 | // Tickmate 4 | // 5 | // Created by Isaac Lyons on 2/23/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Color { 11 | /// Returns the color (excluding alpha) as an RGB color. 12 | var rgb: Int { 13 | guard let components = cgColor?.components, 14 | components.count >= 3 else { 15 | return 0 16 | } 17 | 18 | let r = Int(round(components[0] * 255)) 19 | let g = Int(round(components[1] * 255)) 20 | let b = Int(round(components[2] * 255)) 21 | return r << 16 + g << 8 + b 22 | } 23 | 24 | /// Initialize based on a 24-bit integer RGB value, such as from a hex code. Does not support alpha channel. 25 | /// - Parameter rgb: An integer RGB value without alpha. 26 | init(rgb: Int) { 27 | let r = Double((rgb & 0xff0000) >> 16) / 255 28 | let g = Double((rgb & 0x00ff00) >> 8) / 255 29 | let b = Double((rgb & 0x0000ff)) / 255 30 | self.init(red: r, green: g, blue: b) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Supplementary Views/PageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PageView.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 5/18/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PageView: View { 11 | 12 | var pageCount: Int 13 | @Binding var currentIndex: Int 14 | @ViewBuilder var content: Content 15 | 16 | @GestureState private var translation: CGFloat = 0 17 | @State private var dragging = true 18 | @State private var offset: CGFloat = 0 19 | 20 | var body: some View { 21 | GeometryReader { geo in 22 | HStack(spacing: 0) { 23 | content.frame(width: geo.size.width) 24 | } 25 | .frame(width: geo.size.width, alignment: .leading) 26 | .offset(x: offset) 27 | .gesture( 28 | DragGesture().updating($translation) { value, state, _ in 29 | dragging = true 30 | // If TicksView was actually performant, this shouldn't be 31 | // needed, but it's actually really laggy. Adding as 32 | // minuscule an animation possible magically smoothes it. 33 | withAnimation(.easeOut(duration: 0.05)) { 34 | updateOffset(geometryReaderWidth: geo.size.width) 35 | } 36 | state = (currentIndex == 0 && value.translation.width > 0) 37 | || (currentIndex == pageCount - 1 && value.translation.width < 0) 38 | ? value.translation.width / 3 39 | : value.translation.width 40 | } 41 | .onEnded { value in 42 | let offset = value.predictedEndTranslation.width / geo.size.width 43 | let newIndex = Int((CGFloat(currentIndex) - offset).rounded()) 44 | let adjacentIndex = newIndex > currentIndex 45 | ? min(newIndex, currentIndex + 1) 46 | : newIndex < currentIndex 47 | ? max(newIndex, currentIndex - 1) 48 | : currentIndex 49 | dragging = false 50 | currentIndex = min(max(adjacentIndex, 0), pageCount - 1) 51 | withAnimation(.push) { 52 | updateOffset(geometryReaderWidth: geo.size.width) 53 | } 54 | } 55 | ) 56 | .onChange(of: pageCount) { _ in updatePage() } 57 | .onAppear { 58 | updatePage() 59 | updateOffset(geometryReaderWidth: geo.size.width) 60 | } 61 | } 62 | } 63 | 64 | private func updateOffset(geometryReaderWidth: CGFloat) { 65 | offset = -CGFloat(currentIndex) * geometryReaderWidth + translation 66 | } 67 | 68 | private func updatePage() { 69 | // If scrolled passed the end (such as when 70 | // a group is removed), go to the last page. 71 | if currentIndex > pageCount - 1 { 72 | currentIndex = pageCount - 1 73 | } 74 | } 75 | } 76 | 77 | struct PageView_Previews: PreviewProvider { 78 | static var previews: some View { 79 | PageView(pageCount: 2, currentIndex: .constant(0)) { 80 | Text("One") 81 | Text("Two") 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Supplementary Views/StateEditButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StateEditButton.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 3/8/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // An edit button that references a binding to a @State EditMode rather than the environment's. 11 | // The default EditButton and environment EditMode are buggy and this behaves more predictably. 12 | 13 | struct StateEditButton: View { 14 | 15 | @Binding var editMode: EditMode 16 | var doneText = "Done" 17 | var onTap: () -> Void = {} 18 | 19 | var body: some View { 20 | Button(editMode.isEditing ? doneText : "Edit") { 21 | withAnimation { 22 | editMode = editMode == .active ? .inactive : .active 23 | } 24 | onTap() 25 | } 26 | .animation(.none, value: editMode) 27 | } 28 | } 29 | 30 | struct BindingEditButton_Previews: PreviewProvider { 31 | static var previews: some View { 32 | StateEditButton(editMode: .constant(.inactive)) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Supplementary Views/TextWithCaption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextWithCaption.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 2/23/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TextWithCaption: View { 11 | 12 | var text: String 13 | var caption: String? 14 | 15 | init(text: String, caption: String? = nil) { 16 | self.text = text 17 | self.caption = caption 18 | } 19 | 20 | init(_ text: String, caption: String? = nil) { 21 | self.text = text 22 | self.caption = caption 23 | } 24 | 25 | @ViewBuilder 26 | private var captionView: some View { 27 | if let caption = caption, 28 | !caption.isEmpty { 29 | if #available(iOS 17, visionOS 1, *) { 30 | Text(caption) 31 | .font(.caption) 32 | .foregroundStyle(.secondary) 33 | } else { 34 | Text(caption) 35 | .font(.caption) 36 | .foregroundColor(.secondary) 37 | } 38 | } 39 | } 40 | 41 | var body: some View { 42 | VStack(alignment: .leading) { 43 | Text(text) 44 | captionView 45 | } 46 | } 47 | } 48 | 49 | struct TextWithCaption_Previews: PreviewProvider { 50 | static var previews: some View { 51 | NavigationView { 52 | List { 53 | TextWithCaption(text: "Text", caption: "Caption") 54 | TextWithCaption(text: "Text with no caption", caption: nil) 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Supplementary Views/View+Centered.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Centered.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 5/14/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | private struct CenteredModifier: ViewModifier { 11 | func body(content: Content) -> some View { 12 | HStack { 13 | Spacer() 14 | content 15 | Spacer() 16 | } 17 | } 18 | } 19 | 20 | extension View { 21 | func centered() -> some View { 22 | self.modifier(CenteredModifier()) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Supplementary Views/View+Conditional.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Conditional.swift 3 | // Tickmate 4 | // 5 | // Created by Isaac Lyons on 9/29/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | // From https://www.avanderlee.com/swiftui/conditional-view-modifier/ 12 | /// Applies the given transform if the given condition evaluates to `true`. 13 | /// - Parameters: 14 | /// - condition: The condition to evaluate. 15 | /// - transform: The transform to apply to the source `View`. 16 | /// - Returns: Either the original `View` or the modified `View` if the condition is `true`. 17 | @ViewBuilder 18 | func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { 19 | if condition { 20 | transform(self) 21 | } else { 22 | self 23 | } 24 | } 25 | } 26 | 27 | extension Bool { 28 | static var iOS14: Bool { 29 | guard #available(iOS 15, *) else { return true } 30 | return false 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Supplementary Views/View+DismissKeyboard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+DismissKeyboard.swift 3 | // Tickmate 4 | // 5 | // Created by Isaac Lyons on 5/7/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if canImport(UIKit) 11 | extension View { 12 | func dismissKeyboard() { 13 | UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) 14 | } 15 | } 16 | #endif 17 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Tickmate.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | production 7 | com.apple.developer.icloud-container-identifiers 8 | 9 | iCloud.vc.isv.Tickmate 10 | 11 | com.apple.developer.icloud-services 12 | 13 | CloudKit 14 | 15 | com.apple.security.application-groups 16 | 17 | group.vc.isv.Tickmate 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/TickmateApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TickmateApp.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 2/19/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct TickmateApp: App { 12 | let persistenceController = PersistenceController.shared 13 | //.loadDemo() // Uncomment this to load demo data into a fresh install 14 | 15 | var body: some Scene { 16 | WindowGroup { 17 | ContentView() 18 | .environment(\.managedObjectContext, persistenceController.container.viewContext) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Views/AcknowledgementsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AcknowledgementsView.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 3/10/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AcknowledgementsView: View { 11 | 12 | private var tickmateAcknowledgement = Acknowledgement( 13 | name: "BSD 2-Clause License", 14 | copyright: "2025, Elaine Lyons", 15 | link: nil, 16 | license: .bsd2) 17 | 18 | private var acknowledgements = [ 19 | Acknowledgement(name: "SwiftDate", copyright: "2018 Daniele Margutti", link: "https://github.com/malcommac/SwiftDate", license: .mit), 20 | Acknowledgement(name: "Introspect for SwiftUI", copyright: "2019 Timber Software", link: "https://github.com/siteline/SwiftUI-Introspect", license: .mit) 21 | ] 22 | 23 | @State private var selection: Acknowledgement? 24 | @State private var listID = UUID() 25 | 26 | var body: some View { 27 | List { 28 | Section(header: Text("License")) { 29 | Text("This app is open-source software.") 30 | AcknowledgementLink(tickmateAcknowledgement, selection: $selection) 31 | } 32 | 33 | Section { 34 | Link(destination: URL(string: "https://github.com/lordi/tickmate")!) { 35 | HStack { 36 | TextWithCaption(text: "Based on Tickmate", caption: "by Hannes Gräuler") 37 | Spacer() 38 | Image(systemName: "arrow.up.right") 39 | } 40 | } 41 | } 42 | 43 | Section(header: Text("Libraries")) { 44 | ForEach(acknowledgements, id: \.self) { acknowledgement in 45 | AcknowledgementLink(acknowledgement, selection: $selection) 46 | } 47 | } 48 | } 49 | .buttonStyle(BorderlessButtonStyle()) 50 | .listStyle(InsetGroupedListStyle()) 51 | .navigationTitle("Acknowledgements") 52 | // Workaround for buggy NavigationLink behavior in iOS 14 53 | .id(listID) 54 | .onAppear { 55 | if selection != nil { 56 | selection = nil 57 | listID = UUID() 58 | } 59 | } 60 | } 61 | } 62 | 63 | struct AcknowledgementsView_Previews: PreviewProvider { 64 | static var previews: some View { 65 | NavigationView { 66 | AcknowledgementsView() 67 | } 68 | .navigationViewStyle(StackNavigationViewStyle()) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Views/ArchivedTracksView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArchivedTracksView.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 3/9/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ArchivedTracksView: View { 11 | 12 | @Environment(\.managedObjectContext) private var moc 13 | @State private var editMode = EditMode.inactive 14 | 15 | @EnvironmentObject private var trackController: TrackController 16 | 17 | @State private var selection: Track? 18 | 19 | private var tracks: [Track] { 20 | trackController.archivedTracksFRC.fetchedObjects ?? [] 21 | } 22 | 23 | var body: some View { 24 | Form { 25 | Section { 26 | ForEach(tracks) { track in 27 | TrackCell(track: track, selection: $selection, shouldShowToggle: false) 28 | } 29 | .onDelete(perform: self.delete(_:)) 30 | } 31 | } 32 | .environment(\.editMode, $editMode) 33 | .navigationTitle("Archived tracks") 34 | // TODO: Add bulk unarchiving? 35 | // As far as I can tell, onDelete is only available with ForEach, 36 | // but multiselect is only available with List, so you can only 37 | // do one or the other??? 38 | .toolbar { 39 | ToolbarItem(placement: .primaryAction) { 40 | StateEditButton(editMode: $editMode) 41 | } 42 | } 43 | } 44 | 45 | private func delete(_ indexSet: IndexSet) { 46 | indexSet.map { tracks[$0] }.forEach { 47 | trackController.delete(track: $0, context: moc) 48 | } 49 | trackController.scheduleSave() 50 | trackController.scheduleTimelineRefresh() 51 | } 52 | } 53 | 54 | #Preview { 55 | NavigationView { 56 | ArchivedTracksView() 57 | } 58 | .navigationViewStyle(StackNavigationViewStyle()) 59 | // TODO: Make unified .previewEnvironment() modifier? 60 | .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) 61 | .environmentObject(TrackController(preview: true)) 62 | .environmentObject(ViewControllerContainer()) 63 | } 64 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 2/19/21. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftUIIntrospect 10 | 11 | struct ContentView: View { 12 | 13 | @Environment(\.managedObjectContext) private var moc 14 | 15 | @FetchRequest( 16 | entity: TrackGroup.entity(), 17 | sortDescriptors: GroupController.sortDescriptors, 18 | predicate: NSPredicate(format: "tracks.@count > 0")) 19 | private var groups: FetchedResults 20 | 21 | private var ungroupedTracksFetchRequest = FetchRequest( 22 | entity: Track.entity(), 23 | sortDescriptors: TrackController.sortDescriptors, 24 | predicate: NSPredicate(format: "enabled == YES AND groups.@count == 0"), 25 | animation: .default) 26 | 27 | @AppStorage(Defaults.showAllTracks.rawValue) private var showAllTracks = true 28 | @AppStorage(Defaults.showUngroupedTracks.rawValue) private var showUngroupedTracks = false 29 | @AppStorage(Defaults.onboardingComplete.rawValue) private var onboardingComplete: Bool = false 30 | @AppStorage(Defaults.groupPage.rawValue) private var page = 0 31 | 32 | @StateObject private var trackController = TrackController() 33 | @StateObject private var groupController = GroupController() 34 | @StateObject private var vcContainer = ViewControllerContainer() 35 | @StateObject private var storeController = StoreController() 36 | 37 | @State private var showingSettings = false 38 | @State private var showingTracks = false 39 | @State private var scrollToBottomToggle = false 40 | @State private var showingOnboarding = false 41 | 42 | private var showingAllTracks: Bool { 43 | showAllTracks || groups.count == 0 || !storeController.groupsUnlocked 44 | } 45 | 46 | private var showingUngroupedTracks: Bool { 47 | showUngroupedTracks && ungroupedTracksFetchRequest.wrappedValue.count > 0 && storeController.groupsUnlocked 48 | } 49 | 50 | private var pageCount: Int { 51 | storeController.groupsUnlocked 52 | ? groups.count + showAllTracks.int + showingUngroupedTracks.int 53 | : 1 54 | } 55 | 56 | private var sheetsOnMainView: Bool { 57 | guard #available(iOS 15, *) else { return false } 58 | return true 59 | } 60 | 61 | var body: some View { 62 | NavigationView { 63 | PageView(pageCount: pageCount, currentIndex: $page) { 64 | if showingAllTracks { 65 | TicksView(scrollToBottomToggle: scrollToBottomToggle) 66 | } 67 | 68 | if showingUngroupedTracks { 69 | TicksView(fetchRequest: ungroupedTracksFetchRequest, scrollToBottomToggle: scrollToBottomToggle) 70 | } 71 | 72 | if storeController.groupsUnlocked { 73 | ForEach(groups) { group in 74 | TicksView(group: group, scrollToBottomToggle: scrollToBottomToggle) 75 | } 76 | } 77 | } 78 | .navigationBarTitle("Tickmate", displayMode: .inline) 79 | .toolbar { 80 | ToolbarItem(placement: .navigationBarTrailing) { 81 | Button { 82 | showingTracks = true 83 | } label: { 84 | Label("Tracks", systemImage: "text.justify") 85 | } 86 | .imageScale(.large) 87 | } 88 | ToolbarItem(placement: .navigationBarLeading) { 89 | Button { 90 | showingSettings = true 91 | } label: { 92 | Label("Settings", systemImage: "gear") 93 | } 94 | .imageScale(.large) 95 | } 96 | } 97 | .onChange(of: showAllTracks, perform: updatePage) 98 | .onChange(of: showUngroupedTracks) { value in 99 | // If there are no ungrouped tracks, then nothing needs to change 100 | guard ungroupedTracksFetchRequest.wrappedValue.count > 0 else { return } 101 | updatePage(pageInserted: value) 102 | } 103 | } 104 | .navigationViewStyle(StackNavigationViewStyle()) 105 | .environmentObject(trackController) 106 | .environmentObject(groupController) 107 | .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in 108 | trackController.saveIfScheduled() 109 | } 110 | .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in 111 | print("willEnterForeground") 112 | trackController.checkForNewDay() 113 | } 114 | .onAppear { 115 | groupController.trackController = trackController 116 | 117 | // There have been bugs with page numbers in the past. 118 | // This is just in case the page number gets bugged 119 | // and is scrolled past the edge. 120 | if page < 0 || (page >= pageCount) { 121 | page = 0 122 | } 123 | 124 | if !onboardingComplete { 125 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { 126 | showingOnboarding = true 127 | } 128 | } 129 | } 130 | // Maybe this is more of an SDK thing and not an iOS 15 thing and 131 | // this can be used instead of the EmptyViews in iOS 14 too. 132 | // More testing needed. 133 | .if(sheetsOnMainView) { view in 134 | view.sheet(isPresented: $showingTracks) { 135 | vcContainer.deactivateEditMode() 136 | } content: { 137 | NavigationView { 138 | TracksView(showing: $showingTracks) 139 | } 140 | .environment(\.managedObjectContext, moc) 141 | .environmentObject(trackController) 142 | .environmentObject(groupController) 143 | .environmentObject(vcContainer) 144 | .introspect(.viewController, on: .iOS(.v14, .v15, .v16, .v17)) { vc in 145 | vc.presentationController?.delegate = vcContainer 146 | } 147 | } 148 | .sheet(isPresented: $showingSettings) { 149 | scrollToBottomToggle.toggle() 150 | } content: { 151 | NavigationView { 152 | SettingsView(showing: $showingSettings) 153 | } 154 | .environmentObject(trackController) 155 | .environmentObject(storeController) 156 | } 157 | .sheet(isPresented: $showingOnboarding) { 158 | OnboardingView(showing: $showingOnboarding) 159 | .environment(\.managedObjectContext, moc) 160 | .environmentObject(trackController) 161 | } 162 | } 163 | 164 | if .iOS14 { 165 | // See https://write.as/angelo/stupid-swiftui-tricks-debugging-sheet-dismissal 166 | // for why the sheets are attached to EmptyViews 167 | EmptyView().sheet(isPresented: $showingTracks) { 168 | vcContainer.deactivateEditMode() 169 | } content: { 170 | NavigationView { 171 | TracksView(showing: $showingTracks) 172 | } 173 | .environment(\.managedObjectContext, moc) 174 | .environmentObject(trackController) 175 | .environmentObject(groupController) 176 | .environmentObject(vcContainer) 177 | .introspect(.viewController, on: .iOS(.v14, .v15, .v16, .v17)) { vc in 178 | vc.presentationController?.delegate = vcContainer 179 | } 180 | } 181 | 182 | EmptyView().sheet(isPresented: $showingSettings) { 183 | scrollToBottomToggle.toggle() 184 | } content: { 185 | NavigationView { 186 | SettingsView(showing: $showingSettings) 187 | } 188 | .environmentObject(trackController) 189 | .environmentObject(storeController) 190 | } 191 | 192 | EmptyView().sheet(isPresented: $showingOnboarding) { 193 | OnboardingView(showing: $showingOnboarding) 194 | .environment(\.managedObjectContext, moc) 195 | .environmentObject(trackController) 196 | } 197 | } 198 | } 199 | 200 | private func updatePage(pageInserted: Bool) { 201 | if groups.count > 0 { 202 | page += pageInserted ? 1 : (page == 0 ? 0 : -1) 203 | } 204 | } 205 | 206 | } 207 | 208 | struct ContentView_Previews: PreviewProvider { 209 | static var previews: some View { 210 | ContentView() 211 | .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Views/ExportTracksSelectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExportTracksSelectionView.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 3/27/24. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftDate 10 | 11 | // The original version of this file was written by Trae using Claude-3.5-Sonnet 12 | 13 | struct ExportTracksSelectionView: View { 14 | @EnvironmentObject private var trackController: TrackController 15 | 16 | @State private var selectedTracks: Set = [] 17 | @State private var allAreSelected: Bool = false 18 | @State private var csv: CSV? 19 | @State private var isExporting = false 20 | @State private var alert: AlertItem? 21 | 22 | private struct CSV: Identifiable { 23 | let url: URL 24 | var id: URL { url } 25 | } 26 | 27 | private var allTracks: [Track] { 28 | trackController.fetchedResultsController.fetchedObjects ?? [] 29 | } 30 | 31 | var body: some View { 32 | List(selection: $selectedTracks) { 33 | Section { 34 | Button(allAreSelected ? "Deselect All" : "Select All") { 35 | if allAreSelected { 36 | selectedTracks.removeAll() 37 | } else { 38 | selectedTracks = Set(allTracks) 39 | } 40 | } 41 | } 42 | 43 | Section(header: Text("Select Tracks to export")) { 44 | ForEach(allTracks, id: \.self) { track in 45 | HStack { 46 | if let systemImage = track.systemImage { 47 | Image(systemName: systemImage) 48 | .resizable() 49 | .scaledToFit() 50 | .padding(6) 51 | .frame(width: 36, height: 36) 52 | .foregroundColor(track.lightText ? .white : .black) 53 | .background( 54 | Color(rgb: Int(track.color)) 55 | .cornerRadius(4) 56 | ) 57 | } 58 | Text(track.name ?? "Unnamed Track") 59 | } 60 | } 61 | } 62 | } 63 | .environment(\.editMode, .constant(.active)) 64 | .navigationTitle("CSV Export") 65 | .toolbar { 66 | ToolbarItem(placement: .confirmationAction) { 67 | Button("Export") { 68 | exportSelectedTracksToCSV() 69 | } 70 | .disabled(selectedTracks.isEmpty) 71 | } 72 | } 73 | .onAppear { 74 | // Select all tracks by default 75 | selectedTracks = Set(allTracks) 76 | } 77 | .onChange(of: selectedTracks) { _ in 78 | allAreSelected = selectedTracks.count == allTracks.count 79 | } 80 | .sheet(item: $csv) { csv in 81 | ShareSheet(activityItems: [csv.url]) 82 | } 83 | .alert(alertItem: $alert) 84 | } 85 | 86 | private func exportSelectedTracksToCSV() { 87 | guard !isExporting else { return } 88 | isExporting = true 89 | defer { 90 | isExporting = false 91 | } 92 | 93 | let tracks = Array(selectedTracks) 94 | var csvString = "Date," + tracks.map { $0.name ?? "Unnamed Track" }.joined(separator: ",") + "\n" 95 | 96 | let today = Date() 97 | let dateFormatter = DateFormatter() 98 | dateFormatter.dateStyle = .short 99 | dateFormatter.timeStyle = .none 100 | 101 | // Find the earliest tick date across all selected tracks 102 | var earliestDay = 0 103 | for track in tracks { 104 | let controller = trackController.tickController(for: track) 105 | if let oldestDays = controller.oldestTickDate() { 106 | earliestDay = max(earliestDay, oldestDays) 107 | } 108 | } 109 | 110 | // Generate CSV from earliest date to today 111 | for day in (0...earliestDay).reversed() { 112 | let date = today - day.days 113 | let dateString = dateFormatter.string(from: date) 114 | let tickCounts = tracks.map { track -> String in 115 | let controller = trackController.tickController(for: track) 116 | let count = controller.tickCount(for: day) 117 | return track.multiple ? String(count) : (count > 0 ? "1" : "0") 118 | } 119 | csvString += dateString + "," + tickCounts.joined(separator: ",") + "\n" 120 | } 121 | 122 | let tempDir = FileManager.default.temporaryDirectory 123 | let fileURL = tempDir.appendingPathComponent("tickmate_export.csv") 124 | 125 | do { 126 | try csvString.write(to: fileURL, atomically: true, encoding: .utf8) 127 | csv = CSV(url: fileURL) 128 | } catch { 129 | alert = AlertItem(title: "Error exporting CSV", message: error.localizedDescription) 130 | } 131 | } 132 | } 133 | 134 | struct ExportTracksSelectionView_Previews: PreviewProvider { 135 | static var previews: some View { 136 | NavigationView { 137 | ExportTracksSelectionView() 138 | } 139 | .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) 140 | .environmentObject(TrackController(preview: true)) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Views/GroupView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GroupView.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 5/14/21. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftUIIntrospect 10 | 11 | struct GroupView: View { 12 | 13 | //MARK: Properties 14 | 15 | @FetchRequest( 16 | entity: Track.entity(), 17 | sortDescriptors: TrackController.sortDescriptors, 18 | predicate: NSPredicate(format: "isArchived == NO") 19 | ) 20 | private var allTracks: FetchedResults 21 | 22 | @EnvironmentObject private var vcContainer: ViewControllerContainer 23 | @EnvironmentObject private var trackController: TrackController 24 | 25 | @ObservedObject var group: TrackGroup 26 | 27 | @State private var name = "" 28 | @State private var selectedTracks = Set() 29 | @State private var fixTextField = false 30 | 31 | //MARK: Body 32 | 33 | var body: some View { 34 | Form { 35 | Section(header: Text("Name")) { 36 | TextField("Name", text: $name) 37 | // TODO: Replace with .submitLabel(.done) when iOS 14 is dropped 38 | .introspect(.textField, on: .iOS(.v14, .v15, .v16, .v17)) { textField in 39 | textField.returnKeyType = .done 40 | } 41 | .id(fixTextField) 42 | .onAppear { 43 | // iOS 15 doesn't seem to like actually loading the text field's text on appear 44 | guard #available(iOS 15, *) else { return } 45 | guard #unavailable(iOS 17) else { return } 46 | guard !fixTextField else { return } 47 | fixTextField = true 48 | } 49 | } 50 | 51 | Section(header: Text("Tracks")) { 52 | ForEach(allTracks) { track in 53 | TrackRow(track: track, selectedTracks: $selectedTracks) 54 | } 55 | } 56 | } 57 | .navigationTitle("Group details") 58 | .onAppear { 59 | name = group.wrappedName 60 | if var tracks = group.tracks as? Set { 61 | // Groups shouldn't have archived Tracks. Remove archived Tracks 62 | // that might be here because of sync conflicts on load. 63 | tracks 64 | .filter(\.isArchived) 65 | .forEach { track in tracks.remove(track) } 66 | selectedTracks = tracks 67 | } 68 | } 69 | .onDisappear { 70 | withAnimation { 71 | group.name = name 72 | group.tracks = selectedTracks as NSSet 73 | // If we try using the environment's moc to save, this will 74 | // crash the app if the user makes this view disappear by 75 | // dismissing the sheet. I'm guessing this is because of 76 | // the environment disappearing too, deallocating the moc. 77 | trackController.scheduleSave() 78 | } 79 | } 80 | } 81 | 82 | //MARK: TrackRew 83 | 84 | struct TrackRow: View { 85 | var track: Track 86 | @Binding var selectedTracks: Set 87 | 88 | var body: some View { 89 | Button { 90 | withAnimation(.interactiveSpring()) { 91 | selectedTracks.toggle(track) 92 | } 93 | } label: { 94 | HStack { 95 | Label( 96 | track.name ?? "New Track", 97 | systemImage: track.systemImage ?? "questionmark.square" 98 | ) 99 | 100 | if selectedTracks.contains(track) { 101 | Spacer() 102 | Image(systemName: "checkmark") 103 | #if os(iOS) 104 | .foregroundColor(.accentColor) 105 | #endif 106 | .transition(.scale) 107 | } 108 | } 109 | } 110 | .foregroundColor(.primary) 111 | } 112 | } 113 | } 114 | 115 | struct GroupView_Previews: PreviewProvider { 116 | static var previews: some View { 117 | NavigationView { 118 | GroupView(group: PersistenceController.preview.previewGroup!) 119 | } 120 | .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Views/GroupsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GroupsView.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 5/14/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct GroupsView: View { 11 | 12 | //MARK: Properties 13 | 14 | @Environment(\.managedObjectContext) private var moc 15 | 16 | @AppStorage(Defaults.showAllTracks.rawValue) private var showAllTracks = true 17 | @AppStorage(Defaults.showUngroupedTracks.rawValue) private var showUngroupedTracks = false 18 | 19 | @EnvironmentObject private var trackController: TrackController 20 | @EnvironmentObject private var groupController: GroupController 21 | 22 | @State private var selection: TrackGroup? 23 | 24 | private var groups: [TrackGroup] { 25 | groupController.fetchedResultsController.fetchedObjects ?? [] 26 | } 27 | 28 | //MARK: Body 29 | 30 | var body: some View { 31 | Form { 32 | Section { 33 | Toggle(isOn: $showAllTracks) { 34 | TextWithCaption(text: "All Tracks", caption: "Show group of all tracks") 35 | } 36 | Toggle(isOn: $showUngroupedTracks) { 37 | TextWithCaption(text: "Ungrouped Tracks", caption: "Show group of all ungrouped tracks") 38 | } 39 | 40 | ForEach(groups) { group in 41 | NavigationLink(group.displayName, destination: GroupView(group: group), tag: group, selection: $selection) 42 | } 43 | .onDelete(perform: delete) 44 | .onMove(perform: move) 45 | } 46 | 47 | Section { 48 | Button("Create new group") { 49 | groupController.animateNextChange = true 50 | let newGroup = TrackGroup(index: Int16(groups.count), context: moc) 51 | select(newGroup, delay: 0.25) 52 | } 53 | .centered() 54 | } 55 | } 56 | .navigationTitle("Groups") 57 | .toolbar { 58 | EditButton() 59 | } 60 | } 61 | 62 | //MARK: Functions 63 | 64 | private func delete(_ indexSet: IndexSet) { 65 | indexSet.map { groups[$0] }.forEach(moc.delete) 66 | trackController.scheduleSave() 67 | } 68 | 69 | private func move(_ indices: IndexSet, newOffset: Int) { 70 | var groupIndices = groups.enumerated().map { $0.offset } 71 | groupIndices.move(fromOffsets: indices, toOffset: newOffset) 72 | groupIndices.enumerated().compactMap { offset, element in 73 | element != offset ? (group: groups[element], newIndex: Int16(offset)) : nil 74 | }.forEach { $0.index = $1 } 75 | 76 | PersistenceController.save(context: moc) 77 | } 78 | 79 | private func select(_ group: TrackGroup, delay: Double) { 80 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 81 | selection = group 82 | } 83 | } 84 | } 85 | 86 | //MARK: Previews 87 | 88 | struct GroupsView_Previews: PreviewProvider { 89 | static var previews: some View { 90 | NavigationView { 91 | GroupsView() 92 | } 93 | .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) 94 | .environmentObject(GroupController(preview: true)) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Views/OnboardingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnboardingView.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 3/18/21. 6 | // 7 | 8 | import SwiftUI 9 | import CoreData 10 | 11 | struct OnboardingView: View { 12 | 13 | @Environment(\.managedObjectContext) private var moc 14 | 15 | @EnvironmentObject private var trackController: TrackController 16 | 17 | @Binding var showing: Bool 18 | 19 | let bodyText = "Tickmate is a 1-bit journal for keeping track of any daily occurances." 20 | + " It's great for tracking habits you hope to build or break," 21 | + " so you can visualize your progress over time." 22 | + "\nYou can also track any other occurances you might want to remember." 23 | 24 | @State private var showingPresets = false 25 | 26 | var body: some View { 27 | NavigationView { 28 | VStack { 29 | Spacer() 30 | LogoView() 31 | .frame(maxHeight: 180) 32 | 33 | Spacer() 34 | Text(bodyText) 35 | .padding(.horizontal, 40) 36 | .multilineTextAlignment(.leading) 37 | .minimumScaleFactor(1.0) 38 | .fixedSize(horizontal: false, vertical: true) 39 | 40 | Spacer() 41 | #if os(iOS) 42 | Button(action: start) { 43 | RoundedRectangle(cornerRadius: 10) 44 | .frame(height: 64) 45 | .overlay( 46 | Text("Get started") 47 | .foregroundColor(.white) 48 | .font(.headline) 49 | ) 50 | } 51 | .padding() 52 | #elseif os(visionOS) 53 | Button("Get started", action: start) 54 | #endif 55 | 56 | // Hidden navigation link that can be triggered programmatically with showingPresets 57 | NavigationLink( 58 | destination: PresetTracksView(onSelect: select) 59 | .toolbar { 60 | Button("Close") { 61 | dismiss() 62 | } 63 | }, 64 | isActive: $showingPresets 65 | ) { 66 | EmptyView() 67 | } 68 | .frame(height: 0) 69 | .opacity(0) 70 | } 71 | .navigationTitle("Tickmate") 72 | .padding(.bottom) 73 | } 74 | .navigationViewStyle(StackNavigationViewStyle()) 75 | } 76 | 77 | private func start() { 78 | // Check if this is a new install by checking if there are any Tracks 79 | if trackController.fetchedResultsController.fetchedObjects?.first != nil { 80 | // Thera are existing Tracks, so just dismiss. 81 | dismiss() 82 | } else { 83 | // There are no Tracks, so show the presets. 84 | showingPresets = true 85 | } 86 | } 87 | 88 | private func select(_ trackRepresentation: TrackRepresentation) { 89 | trackController.newTrack(from: trackRepresentation, index: 0, context: moc) 90 | dismiss() 91 | } 92 | 93 | private func dismiss() { 94 | UserDefaults.standard.setValue(true, forKey: Defaults.onboardingComplete.rawValue) 95 | showing = false 96 | } 97 | 98 | } 99 | 100 | struct LogoView: View { 101 | 102 | let colors = [PresetTracks[0].tracks[1].color, PresetTracks[0].tracks[3].color, PresetTracks[0].tracks[7].color] 103 | 104 | let ticked = [ 105 | [true, false, true], 106 | [true, true, false], 107 | [true, true, false], 108 | [false, true, true] 109 | ] 110 | 111 | var body: some View { 112 | VStack { 113 | ForEach(0..<4) { row in 114 | HStack { 115 | ForEach(0..<3) { column in 116 | RoundedRectangle(cornerRadius: 3.0) 117 | .aspectRatio(2, contentMode: .fit) 118 | .foregroundColor(ticked[row][column] ? colors[column] : Color(.systemFill)) 119 | } 120 | } 121 | } 122 | } 123 | } 124 | } 125 | 126 | struct OnboardingView_Previews: PreviewProvider { 127 | static var previews: some View { 128 | Group { 129 | OnboardingView(showing: .constant(true)) 130 | .previewDevice(PreviewDevice(rawValue: "iPhone 12 Pro Max")) 131 | .previewDisplayName("iPhone 12 Pro Max") 132 | 133 | OnboardingView(showing: .constant(true)) 134 | .previewDevice(PreviewDevice(rawValue: "iPhone SE (2nd generation)")) 135 | .previewDisplayName("iPhone SE 2") 136 | 137 | OnboardingView(showing: .constant(true)) 138 | .previewDevice(PreviewDevice(rawValue: "iPhone SE (1st generation)")) 139 | .previewDisplayName("iPhone SE") 140 | 141 | OnboardingView(showing: .constant(true)) 142 | .previewDevice(PreviewDevice(rawValue: "Apple Vision Pro")) 143 | .previewDisplayName("Vision Pro") 144 | } 145 | .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) 146 | .environmentObject(TrackController()) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Views/PresetTracksView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PresetTracksView.swift 3 | // Tickmate 4 | // 5 | // Created by Isaac Lyons on 3/9/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PresetTracksView: View { 11 | 12 | var tracksList: TracksList 13 | var select: (TrackRepresentation) -> Void 14 | 15 | init(tracks: TracksList? = nil, onSelect: @escaping (TrackRepresentation) -> Void) { 16 | self.tracksList = tracks ?? PresetTracks 17 | self.select = onSelect 18 | } 19 | 20 | var body: some View { 21 | Form { 22 | ForEach(tracksList, id: \.title) { list in 23 | Section(header: Text(list.title)) { 24 | ForEach(list.tracks, id: \.self) { track in 25 | Button { 26 | select(track) 27 | } label: { 28 | TrackRepresentationCell(trackRepresentation: track) 29 | } 30 | } 31 | } 32 | } 33 | } 34 | .navigationTitle("Example Tracks") 35 | } 36 | } 37 | 38 | struct TrackRepresentationCell: View { 39 | 40 | var trackRepresentation: TrackRepresentation 41 | 42 | private var caption: String { 43 | [trackRepresentation.multiple ? "Multiple" : nil, trackRepresentation.reversed ? "Reversed" : nil].compactMap { $0 }.joined(separator: ", ") 44 | } 45 | 46 | var body: some View { 47 | HStack { 48 | if let systemImage = trackRepresentation.systemImage { 49 | Image(systemName: systemImage) 50 | .resizable() 51 | .aspectRatio(contentMode: .fit) 52 | .frame(width: 40, height: 40) 53 | .padding() 54 | .background(trackRepresentation.color.cornerRadius(8)) 55 | .foregroundColor(trackRepresentation.lightText ? .white : .black) 56 | } 57 | TextWithCaption(trackRepresentation.name, caption: caption) 58 | .foregroundColor(.primary) 59 | } 60 | } 61 | } 62 | 63 | struct PresetTracksView_Previews: PreviewProvider { 64 | 65 | static var previews: some View { 66 | NavigationView { 67 | PresetTracksView(onSelect: {_ in}) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Views/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 3/9/21. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftDate 10 | 11 | struct SettingsView: View { 12 | 13 | @AppStorage(Defaults.customDayStart.rawValue, store: UserDefaults(suiteName: groupID)) 14 | private var customDayStart: Bool = false 15 | @AppStorage(Defaults.customDayStartMinutes.rawValue, store: UserDefaults(suiteName: groupID)) 16 | private var minutes: Int = 60 17 | @AppStorage(Defaults.weekStartDay.rawValue, store: UserDefaults(suiteName: groupID)) 18 | private var weekStartDay = 2 19 | 20 | @AppStorage(Defaults.todayLock.rawValue, store: UserDefaults(suiteName: groupID)) 21 | private var todayLock = false 22 | 23 | @AppStorage(Defaults.todayAtTop.rawValue, store: UserDefaults(suiteName: groupID)) 24 | private var todayAtTop = false 25 | 26 | @AppStorage(Defaults.weekSeparatorSpaces.rawValue) private var weekSeparatorSpaces: Bool = true 27 | @AppStorage(Defaults.weekSeparatorLines.rawValue) private var weekSeparatorLines: Bool = true 28 | @AppStorage(Defaults.relativeDates.rawValue) private var relativeDates = true 29 | 30 | let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String 31 | let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String 32 | 33 | @EnvironmentObject private var trackController: TrackController 34 | @EnvironmentObject private var storeController: StoreController 35 | 36 | @Binding var showing: Bool 37 | 38 | @State private var timeOffset: Date = Date() 39 | @State private var showingRestrictedPaymentsAlert = false 40 | 41 | var body: some View { 42 | Form { 43 | Section { 44 | Toggle(isOn: $customDayStart.animation()) { 45 | Text("Custom day rollover time") 46 | } 47 | 48 | if customDayStart { 49 | DatePicker(selection: $timeOffset, displayedComponents: [.hourAndMinute]) { 50 | Text("New day start time") 51 | } 52 | } 53 | } footer: { 54 | Text("If you frequently use the app past midnight, you can set a custom day rollover time so “today” won't change until later in the morning.") 55 | } 56 | 57 | Section { 58 | Toggle(isOn: $todayLock) { 59 | Text("Today Lock \(Image(systemName: todayLock ? "lock" : "lock.open"))") 60 | } 61 | } footer: { 62 | Text("Only allow ticking the current day. Prevents accidental changes to past dates.") 63 | } 64 | 65 | Section { 66 | Picker("Put today at the", selection: $todayAtTop) { 67 | Text("top") 68 | .tag(true) 69 | Text("bottom") 70 | .tag(false) 71 | } 72 | Toggle(isOn: $relativeDates) { 73 | TextWithCaption(text: "Use relative dates", caption: "Today, Yesterday") 74 | } 75 | } 76 | 77 | Section(header: Text("Week Separators")) { 78 | Toggle("Separator lines", isOn: $weekSeparatorLines) 79 | Toggle("Separator spaces", isOn: $weekSeparatorSpaces) 80 | Picker("Week starts on", selection: $weekStartDay) { 81 | Text("Monday") 82 | .tag(2) 83 | Text("Tuesday") 84 | .tag(3) 85 | Text("Wednesday") 86 | .tag(4) 87 | Text("Thursday") 88 | .tag(5) 89 | Text("Friday") 90 | .tag(6) 91 | Text("Saturday") 92 | .tag(7) 93 | Text("Sunday") 94 | .tag(1) 95 | } 96 | } 97 | 98 | Section(header: Text("Premium Features"), footer: Text("Groups allow you to swipe left and right between different sets of tracks from the main screen")) { 99 | if !storeController.isGroupsProductAvailable { 100 | TextWithCaption( 101 | "Premium features are not available in the EU", 102 | caption: "If you are seeing this and are not in the EU, consider contacting me below." 103 | ) 104 | } else if let product = storeController.groupsProduct { 105 | Button { 106 | storeController.isAuthorizedForPayments 107 | ? storeController.purchase(product) 108 | : (showingRestrictedPaymentsAlert = true) 109 | } label: { 110 | HStack { 111 | TextWithCaption(product.localizedTitle, caption: product.localizedDescription) 112 | .foregroundColor(.primary) 113 | Spacer() 114 | if storeController.purchased.contains(product.productIdentifier) { 115 | Text("Purchased!") 116 | .foregroundColor(.secondary) 117 | } else if storeController.purchasing.contains(product.productIdentifier) { 118 | ProgressView() 119 | } else { 120 | Text( 121 | product.price, 122 | formatter: storeController.priceFormatter 123 | ) 124 | #if os(iOS) 125 | .foregroundColor(storeController.isAuthorizedForPayments ? .accentColor : .secondary) 126 | #endif 127 | } 128 | } 129 | } 130 | .disabled(storeController.purchased.contains(product.productIdentifier)) 131 | .alert(isPresented: $showingRestrictedPaymentsAlert) { 132 | Alert(title: Text("Access restricted"), message: Text("You don't have permission to make purchases on this account.")) 133 | } 134 | } else { 135 | ProgressView() 136 | } 137 | 138 | Button("Restore purchases") { 139 | storeController.restorePurchases() 140 | } 141 | .alert(alertItem: $storeController.restored) 142 | 143 | #if DEBUG 144 | Button("Reset purchases (debug feature)") { 145 | StoreController.Products.allCases.forEach { 146 | UserDefaults.standard.set(false, forKey: $0.rawValue) 147 | storeController.removePurchased(id: $0.rawValue) 148 | } 149 | } 150 | #endif 151 | } 152 | 153 | Section(header: Text("App Information")) { 154 | if let version = appVersion, 155 | let build = appBuild { 156 | HStack { 157 | Text("Version") 158 | Spacer() 159 | Text("\(version) (\(build))") 160 | .foregroundColor(.secondary) 161 | } 162 | } 163 | NavigationLink("Acknowledgements", destination: AcknowledgementsView()) 164 | } 165 | 166 | Section(header: Text("Data Export")) { 167 | NavigationLink("Export as CSV", destination: ExportTracksSelectionView()) 168 | } 169 | 170 | Section { 171 | Link("Support Website", destination: URL(string: "https://github.com/skjiisa/Tickmate-iOS/issues")!) 172 | Link("Email Me", destination: URL(string: "mailto:tickmate@lyons.app")!) 173 | Link("Privacy Policy", destination: URL(string: "https://github.com/skjiisa/Tickmate-iOS/blob/main/Privacy%20Policy.txt")!) 174 | Link("Source Code", destination: URL(string: "https://github.com/skjiisa/Tickmate-iOS/")!) 175 | } 176 | } 177 | .navigationTitle("Settings") 178 | .toolbar { 179 | // A WWDC talk said to always put close buttons in the top left, at 180 | // least for visionOS. Do they mean _left_ left, or leading?? 181 | ToolbarItem(placement: .cancellationAction) { 182 | Button("Done") { 183 | showing = false 184 | } 185 | } 186 | } 187 | .onAppear { 188 | if let date = DateInRegion(components: { dateComponents in 189 | dateComponents.minute = minutes 190 | }, region: .current) { 191 | timeOffset = date.date 192 | } 193 | storeController.fetchProducts() 194 | } 195 | .onChange(of: customDayStart, perform: updateCustomDayStart) 196 | .onChange(of: timeOffset, perform: updateCustomDayStart) 197 | .onChange(of: todayAtTop) { _ in 198 | trackController.scheduleTimelineRefresh() 199 | } 200 | .onChange(of: weekStartDay) { value in 201 | trackController.weekStartDay = value 202 | } 203 | .onChange(of: relativeDates) { value in 204 | trackController.relativeDates = value 205 | } 206 | } 207 | 208 | private let dateFormatter: DateFormatter = { 209 | let formatter = DateFormatter() 210 | formatter.dateStyle = .none 211 | formatter.timeStyle = .short 212 | return formatter 213 | }() 214 | 215 | private func updateCustomDayStart(_: Any? = nil) { 216 | let components = timeOffset.in(region: .current).dateComponents 217 | minutes = (components.hour ?? 0) * 60 + (components.minute ?? 0) 218 | trackController.setCustomDayStart(minutes: minutes) 219 | } 220 | 221 | } 222 | 223 | struct SettingsView_Previews: PreviewProvider { 224 | static var previews: some View { 225 | NavigationView { 226 | SettingsView(showing: .constant(true)) 227 | } 228 | .navigationViewStyle(StackNavigationViewStyle()) 229 | .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) 230 | .environmentObject(TrackController(preview: true)) 231 | .environmentObject(GroupController(preview: true)) 232 | .environmentObject(StoreController()) 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Views/ShareSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShareSheet.swift 3 | // Tickmate 4 | // 5 | // Created by Trae AI on 3/14/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// This type was written by Trae using Claude-3.5-Sonnet 11 | struct ShareSheet: UIViewControllerRepresentable { 12 | let activityItems: [Any] 13 | let applicationActivities: [UIActivity]? = nil 14 | 15 | func makeUIViewController(context: Context) -> UIActivityViewController { 16 | let controller = UIActivityViewController( 17 | activityItems: activityItems, 18 | applicationActivities: applicationActivities 19 | ) 20 | return controller 21 | } 22 | 23 | func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} 24 | } 25 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Views/SymbolPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SymbolPicker.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 2/24/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SymbolPicker: View { 11 | 12 | @Binding var selection: String? 13 | 14 | var colunms = [GridItem(), GridItem(), GridItem(), GridItem(), GridItem()] 15 | 16 | var body: some View { 17 | ScrollViewReader { proxy in 18 | ScrollView(.vertical) { 19 | LazyVGrid(columns: colunms, spacing: 16) { 20 | ForEach(SymbolsList, id: \.self) { symbol in 21 | Button { 22 | selection = symbol 23 | } label: { 24 | ZStack { 25 | RoundedRectangle(cornerRadius: 8) 26 | .foregroundColor(selection == symbol ? .accentColor : Color(.systemFill)) 27 | .aspectRatio(1, contentMode: .fill) 28 | Image(systemName: symbol) 29 | .imageScale(.large) 30 | .foregroundColor( 31 | selection == symbol ? .white : .primary 32 | ) 33 | } 34 | } 35 | .id(symbol) 36 | // If you have this many buttons at once, they HAVE to 37 | // be plain or borderless or everything lags like hell. 38 | .buttonStyle(.plain) 39 | #if os(visionOS) 40 | .buttonBorderShape(.roundedRectangle(radius: 8)) 41 | #endif 42 | } 43 | } 44 | .padding() 45 | } 46 | .onAppear { 47 | proxy.scrollTo(selection, anchor: .center) 48 | } 49 | } 50 | .navigationBarTitle("Symbols", displayMode: .inline) 51 | } 52 | } 53 | 54 | struct SymbolPicker_Previews: PreviewProvider { 55 | static var previews: some View { 56 | Preview() 57 | } 58 | 59 | struct Preview: View { 60 | @State private var selection: String? = "cube" 61 | var body: some View { 62 | NavigationView { 63 | SymbolPicker(selection: $selection) 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Views/TickView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TickView.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 6/24/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | //MARK: Day Row 11 | 12 | struct DayRow: View where C.Element == Track { 13 | 14 | @EnvironmentObject private var trackController: TrackController 15 | 16 | let day: Int 17 | var tracks: C 18 | var spaces: Bool 19 | var lines: Bool 20 | var widget: Bool 21 | var compact: Bool 22 | var canEdit: Bool 23 | 24 | init(_ day: Int, tracks: C, spaces: Bool, lines: Bool, canEdit: Bool) { 25 | self.init(day, tracks: tracks, spaces: spaces, lines: lines, widget: false, compact: false, canEdit: canEdit) 26 | } 27 | 28 | init(_ day: Int, tracks: C, spaces: Bool, lines: Bool, widget: Bool, compact: Bool, canEdit: Bool) { 29 | self.day = day 30 | self.tracks = tracks 31 | self.spaces = spaces 32 | self.lines = lines 33 | self.widget = widget 34 | self.compact = compact 35 | self.canEdit = canEdit 36 | } 37 | 38 | @ViewBuilder 39 | private var background: some View { 40 | if lines && trackController.shouldShowSeparatorBelow(day: day) { 41 | VStack { 42 | Spacer() 43 | Capsule() 44 | .foregroundColor(.gray) 45 | .frame(height: 4) 46 | .offset(x: 12, y: 0) 47 | } 48 | } 49 | } 50 | 51 | var body: some View { 52 | VStack(alignment: .leading, spacing: nil) { 53 | if spaces && trackController.insets(day: day) == .top { 54 | Rectangle() 55 | .frame(height: 0) 56 | .opacity(0) 57 | } 58 | HStack(spacing: 4) { 59 | let label = trackController.dayLabel(day: day, compact: widget) 60 | TextWithCaption(label.text, caption: widget ? nil : label.caption) 61 | .lineLimit(1) 62 | .frame(width: compact ? 30 : widget ? 50 : 80, alignment: .leading) 63 | .font(compact ? .system(size: 11) : .body) 64 | ForEach(tracks) { track in 65 | TickView( 66 | day: day, 67 | widget: widget, 68 | compact: compact, 69 | canEdit: canEdit, 70 | track: track, 71 | tickController: trackController.tickController(for: track) 72 | ) 73 | } 74 | } 75 | if spaces && trackController.insets(day: day) == .bottom { 76 | Rectangle() 77 | // Make up for the height of the separator line if present 78 | .frame(height: lines ? 4 : 0) 79 | .opacity(0) 80 | } 81 | } 82 | .listRowBackground(background) 83 | .id(day) 84 | } 85 | } 86 | 87 | //MARK: Tick View 88 | 89 | struct TickView: View { 90 | 91 | @EnvironmentObject private var trackController: TrackController 92 | 93 | let day: Int 94 | var widget: Bool 95 | var compact: Bool 96 | var canEdit: Bool 97 | 98 | @ObservedObject var track: Track 99 | @ObservedObject var tickController: TickController 100 | 101 | private var color: Color { 102 | // If the day is ticked, use the track color. Otherwise, use 103 | // system fill. If the track is reversed, reverse the check. 104 | (tickController.tickCount(for: day) > 0) != track.reversed ? Color(rgb: Int(track.color)) : Color(.systemFill) 105 | } 106 | 107 | private var validDate: Bool { 108 | !track.reversed || day <= tickController.todayOffset ?? 0 109 | } 110 | 111 | @State private var pressing = false 112 | 113 | var body: some View { 114 | ZStack { 115 | if widget { 116 | RoundedRectangle(cornerRadius: 3) 117 | .foregroundColor(color) 118 | } else { 119 | RoundedRectangle(cornerRadius: 3) 120 | .foregroundColor(color) 121 | .frame(height: 32) 122 | } 123 | let count = tickController.tickCount(for: day) 124 | if count > 1 { 125 | Text("\(count)") 126 | .lineLimit(1) 127 | .foregroundColor(track.lightText ? .white : .black) 128 | .font(compact ? .system(size: 11) : .body) 129 | } 130 | } 131 | .hoverEffect() 132 | .onTapGesture { 133 | guard canEdit else { 134 | trackController.didTapLockedDay() 135 | return 136 | } 137 | tickController.tick(day: day) 138 | // TODO: Use CoreHaptics for audio feedback on visionOS? 139 | // Or .sensoryFeedback (???) 140 | // Or just replace with a native button 141 | #if os(iOS) 142 | UISelectionFeedbackGenerator().selectionChanged() 143 | #endif 144 | } 145 | .onLongPressGesture { pressing in 146 | guard track.multiple, canEdit else { return } 147 | withAnimation(pressing ? .easeInOut(duration: 0.6) : .interactiveSpring()) { 148 | self.pressing = pressing 149 | } 150 | } perform: { 151 | guard track.multiple, canEdit else { return } 152 | if tickController.untick(day: day) { 153 | #if os(iOS) 154 | UIImpactFeedbackGenerator(style: .soft).impactOccurred() 155 | #endif 156 | } 157 | } 158 | .scaleEffect(pressing ? 1.1 : 1) 159 | .opacity(validDate ? 1 : 0) 160 | .disabled(!validDate) 161 | } 162 | } 163 | 164 | /* 165 | struct TickView_Previews: PreviewProvider { 166 | static var previews: some View { 167 | DayRow() 168 | } 169 | } 170 | */ 171 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Views/TicksView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TicksView.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 2/19/21. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftDate 10 | import SwiftUIIntrospect 11 | 12 | //MARK: Ticks View 13 | 14 | struct TicksView: View { 15 | 16 | // MARK: Properties 17 | 18 | @Environment(\.managedObjectContext) private var moc 19 | 20 | private var fetchRequest: FetchRequest 21 | private var tracks: FetchedResults { 22 | fetchRequest.wrappedValue 23 | } 24 | 25 | @AppStorage(Defaults.todayLock.rawValue, store: UserDefaults(suiteName: groupID)) 26 | private var todayLock = false 27 | 28 | @AppStorage(Defaults.todayAtTop.rawValue, store: UserDefaults(suiteName: groupID)) 29 | private var todayAtTop = false 30 | 31 | @AppStorage(Defaults.weekSeparatorLines.rawValue) private var weekSeparatorLines: Bool = true 32 | @AppStorage(Defaults.weekSeparatorSpaces.rawValue) private var weekSeparatorSpaces: Bool = true 33 | 34 | @EnvironmentObject private var groupController: GroupController 35 | @EnvironmentObject private var trackController: TrackController 36 | 37 | var scrollToBottomToggle: Bool = false 38 | 39 | @StateObject private var vcContainer = ViewControllerContainer() 40 | 41 | @State private var showingTrack: Track? 42 | 43 | // MARK: Init 44 | 45 | private static var standardPredicate: String = "enabled == YES AND isArchived == NO" 46 | 47 | init(scrollToBottomToggle: Bool = false) { 48 | self.scrollToBottomToggle = scrollToBottomToggle 49 | fetchRequest = FetchRequest( 50 | entity: Track.entity(), 51 | sortDescriptors: TrackController.sortDescriptors, 52 | predicate: NSPredicate(format: Self.standardPredicate) 53 | ) 54 | } 55 | 56 | init(group: TrackGroup, scrollToBottomToggle: Bool = false) { 57 | self.scrollToBottomToggle = scrollToBottomToggle 58 | fetchRequest = FetchRequest( 59 | entity: Track.entity(), 60 | sortDescriptors: TrackController.sortDescriptors, 61 | predicate: NSPredicate( 62 | format: Self.standardPredicate + " AND %@ IN groups", 63 | group 64 | ) 65 | ) 66 | } 67 | 68 | init(fetchRequest: FetchRequest, scrollToBottomToggle: Bool = false) { 69 | self.fetchRequest = fetchRequest 70 | self.scrollToBottomToggle = scrollToBottomToggle 71 | } 72 | 73 | var body: some View { 74 | VStack(spacing: 0) { 75 | HStack(spacing: 4) { 76 | Rectangle() 77 | .opacity(0) 78 | .frame(width: 80, height: 32) 79 | ForEach(tracks) { track in 80 | Button { 81 | showingTrack = track 82 | #if os(iOS) 83 | UISelectionFeedbackGenerator().selectionChanged() 84 | #endif 85 | } label: { 86 | ZStack { 87 | #if os(iOS) 88 | RoundedRectangle(cornerRadius: 3) 89 | .foregroundColor(Color(.systemFill)) 90 | #elseif os(visionOS) 91 | Color.clear 92 | #endif 93 | if let systemImage = track.systemImage { 94 | Text("\(Image(systemName: systemImage))") 95 | } 96 | } 97 | .frame(height: 32) 98 | } 99 | .foregroundColor(.primary) 100 | .onAppear { 101 | trackController.loadTicks(for: track) 102 | } 103 | #if os(visionOS) 104 | .buttonStyle(.borderless) 105 | #endif 106 | } 107 | } 108 | #if os(visionOS) 109 | .padding(.horizontal, 8) 110 | #endif 111 | .padding(.horizontal) 112 | .padding(.vertical, 4) 113 | .sheet(item: $showingTrack) { 114 | vcContainer.deactivateEditMode() 115 | } content: { track in 116 | NavigationView { 117 | TrackView(track: track, selection: $showingTrack, sheet: true) 118 | } 119 | .environmentObject(vcContainer) 120 | .environmentObject(trackController) 121 | .environmentObject(groupController) 122 | .introspect(.viewController, on: .iOS(.v14, .v15, .v16, .v17)) { vc in 123 | vc.presentationController?.delegate = vcContainer 124 | } 125 | } 126 | 127 | Divider() 128 | 129 | ScrollViewReader { proxy in 130 | List { 131 | if !todayAtTop { 132 | Button("Go to bottom") { 133 | proxy.scrollTo(0) 134 | } 135 | } 136 | 137 | ForEach(0..<365) { row in 138 | let day = todayAtTop ? row : 364 - row 139 | DayRow( 140 | day, 141 | tracks: tracks, 142 | spaces: weekSeparatorSpaces, 143 | lines: weekSeparatorLines, 144 | canEdit: day == 0 || !todayLock 145 | ) 146 | .listRowInsets(.init(top: 4, leading: 0, bottom: 4, trailing: 0)) 147 | #if os(iOS) 148 | .padding(.horizontal) 149 | #endif 150 | } 151 | } 152 | .listStyle(PlainListStyle()) 153 | .introspect(.list, on: .iOS(.v14, .v15)) { tableView in 154 | tableView.scrollsToTop = todayAtTop 155 | } 156 | .introspect(.list, on: .iOS(.v16, .v17)) { collectionView in 157 | collectionView.scrollsToTop = todayAtTop 158 | } 159 | .padding(0) 160 | .onAppear { 161 | proxy.scrollTo(0, anchor: .top) 162 | } 163 | .onChange(of: scrollToBottomToggle) { _ in 164 | withAnimation { 165 | proxy.scrollTo(0, anchor: .top) 166 | } 167 | } 168 | } 169 | } 170 | .alert(alertItem: $trackController.todayLockAlert) 171 | } 172 | } 173 | 174 | //MARK: Preview 175 | 176 | struct TicksView_Previews: PreviewProvider { 177 | static var previews: some View { 178 | NavigationView { 179 | TicksView() 180 | } 181 | .navigationViewStyle(StackNavigationViewStyle()) 182 | .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) 183 | .environmentObject(TrackController(preview: true)) 184 | .environmentObject(GroupController(preview: true)) 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Views/TrackView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrackView.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 2/23/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | //MARK: TrackView 11 | 12 | struct TrackView: View { 13 | 14 | //MARK: Properties 15 | 16 | @Environment(\.managedObjectContext) private var moc 17 | 18 | @AppStorage(StoreController.Products.groups.rawValue) private var groupsUnlocked: Bool = false 19 | 20 | @EnvironmentObject private var vcContainer: ViewControllerContainer 21 | @EnvironmentObject private var trackController: TrackController 22 | 23 | @ObservedObject var track: Track 24 | @Binding var selection: Track? 25 | let sheet: Bool 26 | 27 | @StateObject private var groups = TrackGroups() 28 | @State private var draftTrack = TrackRepresentation() 29 | @State private var enabled = true 30 | @State private var initialized = false 31 | @State private var showingSymbolPicker = false 32 | @State private var actionSheet: Action? 33 | @State private var fixTextField = false 34 | 35 | private var groupsEnabled: Bool { 36 | groupsUnlocked && !track.isArchived 37 | } 38 | 39 | @ViewBuilder 40 | private var groupsFooter: some View { 41 | if !groupsUnlocked { 42 | Text("Unlock the groups upgrade from the settings page.") 43 | } else if track.isArchived { 44 | Text("Unarchive to add to groups.") 45 | } 46 | } 47 | 48 | //MARK: Body 49 | 50 | var body: some View { 51 | Form { 52 | Section(footer: groupsFooter) { 53 | Toggle("Enabled", isOn: $enabled) 54 | NavigationLink(destination: GroupsPicker(track: track, groups: groups)) { 55 | HStack { 56 | Text("Groups") 57 | Spacer() 58 | Text(groups.name) 59 | .lineLimit(1) 60 | .foregroundColor(.secondary) 61 | } 62 | } 63 | .disabled(!groupsEnabled) 64 | } 65 | 66 | Section(header: Text("Name")) { 67 | TextField("Name", text: $draftTrack.name) 68 | .introspect(.textField, on: .iOS(.v14, .v15, .v16, .v17)) { textField in 69 | vcContainer.textField = textField 70 | vcContainer.shouldReturn = { 71 | // There is a bug with SwiftUI TextFields that causes them 72 | // to revert autocorrect changes on return. Dismissing the 73 | // keyboad before returning fixes the issue visually. Setting 74 | // the name to the UITextField's text fixes it mechanically. 75 | correctedKeyboardDismiss() 76 | setEditMode() 77 | return false 78 | } 79 | vcContainer.textFieldShouldEnableEditMode = true 80 | textField.delegate = vcContainer 81 | } 82 | .id(fixTextField) 83 | .onAppear { 84 | // iOS 15 doesn't seem to like actually loading the text field's text on appear 85 | guard #available(iOS 15, *) else { return } 86 | guard #unavailable(iOS 17) else { return } 87 | guard !fixTextField else { return } 88 | fixTextField = true 89 | } 90 | } 91 | 92 | Section(header: Text("Settings")) { 93 | Toggle(isOn: $draftTrack.multiple.onChange(setEditMode)) { 94 | TextWithCaption( 95 | text: "Allow multiple", 96 | caption: "Multiple ticks on a day will be counted." 97 | + " Long press to decrease counter.") 98 | } 99 | 100 | Toggle(isOn: $draftTrack.reversed.animation().onChange(setEditMode)) { 101 | TextWithCaption( 102 | text: "Reversed", 103 | caption: "Days will be ticked by default." 104 | + " Tapping a day will untick it." 105 | + " Good for tracking abstaining from bad habits.") 106 | } 107 | 108 | if draftTrack.reversed { 109 | DatePicker(selection: $draftTrack.startDate.onChange(setEditMode), in: Date.distantPast...trackController.date, displayedComponents: [.date]) { 110 | TextWithCaption( 111 | text: "Start date", 112 | caption: "Days after this will automatically be ticked unless you untick them.") 113 | } 114 | } 115 | 116 | ColorPicker("Color", selection: $draftTrack.color.onChange(setEditMode), supportsOpacity: false) 117 | 118 | NavigationLink( 119 | destination: SymbolPicker(selection: $draftTrack.systemImage.onChange({ _ in 120 | showingSymbolPicker = false 121 | setEditMode() 122 | })), 123 | isActive: $showingSymbolPicker) { 124 | HStack { 125 | Text("Symbol") 126 | Spacer() 127 | if let symbol = draftTrack.systemImage { 128 | Image(systemName: symbol) 129 | .imageScale(.large) 130 | } 131 | } 132 | } 133 | } 134 | 135 | if !vcContainer.editMode.isEditing { 136 | footerSection 137 | } 138 | } 139 | .navigationTitle("Track details") 140 | .toolbar { 141 | ToolbarItem(placement: .primaryAction) { 142 | StateEditButton(editMode: $vcContainer.editMode, doneText: "Save") { 143 | if vcContainer.editMode == .inactive { 144 | save() 145 | } 146 | } 147 | } 148 | ToolbarItem(placement: .cancellationAction) { 149 | if sheet || vcContainer.editMode.isEditing { 150 | Button(vcContainer.editMode.isEditing ? "Cancel" : "Done") { 151 | vcContainer.editMode.isEditing ? cancel() : (selection = nil) 152 | } 153 | } 154 | } 155 | } 156 | .navigationBarBackButtonHidden(vcContainer.editMode.isEditing) 157 | .onChange(of: vcContainer.editMode) { value in 158 | if !value.isEditing { 159 | correctedKeyboardDismiss() 160 | } 161 | } 162 | .onAppear { 163 | if !initialized { 164 | enabled = track.enabled 165 | groups.load(track) 166 | draftTrack.load(track: track) 167 | initialized = true 168 | } 169 | setEditMode() 170 | } 171 | .onDisappear { 172 | if enabled != track.enabled { 173 | track.enabled = enabled 174 | PersistenceController.save(context: moc) 175 | } 176 | vcContainer.deactivateEditMode() 177 | } 178 | } 179 | 180 | // MARK: Footer section 181 | 182 | private var footerSection: some View { 183 | Section { 184 | if #available(iOS 15, *) { 185 | Button("Delete", role: .destructive) { 186 | actionSheet = .delete 187 | } 188 | } else { 189 | Button("Delete") { 190 | actionSheet = .delete 191 | } 192 | .accentColor(.red) 193 | } 194 | 195 | Button(track.isArchived ? "Unarchive" : "Archive") { 196 | actionSheet = track.isArchived ? .unarchive : .archive 197 | } 198 | } 199 | .actionSheet(item: $actionSheet, content: self.actionSheet(for:)) 200 | } 201 | 202 | private enum Action: Hashable, Identifiable { 203 | var id: Action { self } 204 | case delete 205 | case archive 206 | case unarchive 207 | } 208 | 209 | private func actionSheet(for action: Action) -> ActionSheet { 210 | switch action { 211 | case .delete: 212 | ActionSheet( 213 | title: Text("Are you sure you want to delete \(draftTrack.name.isEmpty ? "this track" : draftTrack.name)?"), 214 | buttons: [ 215 | .destructive(Text("Delete"), action: self.delete), 216 | .cancel() 217 | ] 218 | ) 219 | case .archive: 220 | ActionSheet( 221 | title: Text(Strings.archiveActionSheetTitle), 222 | message: Text(Strings.archiveActionSheetMessage), 223 | buttons: [ 224 | .destructive(Text("Archive"), action: self.archive), 225 | .cancel(), 226 | ] 227 | ) 228 | case .unarchive: 229 | ActionSheet( 230 | title: Text("Unarchive track?"), 231 | buttons: [ 232 | .default(Text("Unarchive"), action: self.unarchive), 233 | .cancel(), 234 | ] 235 | ) 236 | } 237 | } 238 | 239 | //MARK: Functions 240 | 241 | private func correctedKeyboardDismiss() { 242 | dismissKeyboard() 243 | if let correctedText = vcContainer.textField?.text { 244 | draftTrack.name = correctedText 245 | } 246 | } 247 | 248 | private func setEditMode(_: Any? = nil) { 249 | vcContainer.editMode = draftTrack == track ? .inactive : .active 250 | } 251 | 252 | private func save() { 253 | correctedKeyboardDismiss() 254 | trackController.save(draftTrack, to: track, context: moc) 255 | } 256 | 257 | private func cancel() { 258 | if track.name == "New Track", 259 | track.ticks?.anyObject() == nil { 260 | // This is a brand new track. Delete it instead of exiting edit mode 261 | delete() 262 | } else { 263 | withAnimation { 264 | draftTrack.load(track: track) 265 | // In case the user entered edit mode without making any changes, 266 | // which would mean onChange(of: draftTrack) wouldn't get called. 267 | setEditMode() 268 | } 269 | } 270 | } 271 | 272 | private func delete() { 273 | selection = nil 274 | 275 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { 276 | withAnimation { 277 | trackController.objectWillChange.send() 278 | trackController.delete(track: track, context: moc) 279 | PersistenceController.save(context: moc) 280 | } 281 | } 282 | } 283 | 284 | private func archive() { 285 | track.archive() 286 | PersistenceController.save(context: moc) 287 | selection = nil 288 | } 289 | 290 | private func unarchive() { 291 | track.isArchived = false 292 | PersistenceController.save(context: moc) 293 | } 294 | 295 | // MARK: Strings 296 | 297 | private enum Strings { 298 | static var archiveActionSheetTitle: LocalizedStringKey = "Are you sure?" 299 | static var archiveActionSheetMessage: LocalizedStringKey = "Archiving a track will hide it from the main track list. It will still be viewable from the archived tracks page at the bottom of the track list." 300 | } 301 | } 302 | 303 | //MARK: GroupsPicker 304 | 305 | struct GroupsPicker: View { 306 | 307 | @EnvironmentObject private var trackController: TrackController 308 | @EnvironmentObject private var groupController: GroupController 309 | 310 | @ObservedObject var track: Track 311 | @ObservedObject var groups: TrackGroups 312 | 313 | private var allGroups: [TrackGroup] { 314 | groupController.fetchedResultsController.fetchedObjects ?? [] 315 | } 316 | 317 | var body: some View { 318 | Form { 319 | ForEach(allGroups) { group in 320 | Button { 321 | withAnimation(.interactiveSpring()) { 322 | groups.toggle(group, in: track) 323 | } 324 | } label: { 325 | HStack { 326 | Text(group.displayName) 327 | if groups.contains(group) { 328 | Spacer() 329 | Image(systemName: "checkmark") 330 | #if os(iOS) 331 | .foregroundColor(.accentColor) 332 | #endif 333 | // TODO: This only seems to animate when hiding 334 | .transition(.scale) 335 | } 336 | } 337 | } 338 | .foregroundColor(.primary) 339 | } 340 | } 341 | .navigationTitle("Groups") 342 | .onDisappear { 343 | withAnimation { 344 | groups.save(to: track) 345 | trackController.scheduleSave() 346 | } 347 | } 348 | } 349 | } 350 | 351 | //MARK: Previews 352 | 353 | struct TrackView_Previews: PreviewProvider { 354 | 355 | static var track: Track = { 356 | try! PersistenceController.preview.container.viewContext.fetch(Track.fetchRequest()).first! 357 | }() 358 | 359 | static var previews: some View { 360 | NavigationView { 361 | TrackView(track: track, selection: .constant(nil), sheet: false) 362 | .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) 363 | .environmentObject(ViewControllerContainer()) 364 | .environmentObject(TrackController()) 365 | .environmentObject(GroupController()) 366 | } 367 | .navigationViewStyle(StackNavigationViewStyle()) 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /Tickmate/Tickmate/Views/TracksView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TracksView.swift 3 | // Tickmate 4 | // 5 | // Created by Elaine Lyons on 2/28/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | //MARK: TracksView 11 | 12 | struct TracksView: View { 13 | 14 | //MARK: Properties 15 | 16 | @Environment(\.managedObjectContext) private var moc 17 | // The environment EditMode is buggy, so using a custom @State property instead 18 | @State private var editMode = EditMode.inactive 19 | 20 | @AppStorage(StoreController.Products.groups.rawValue) private var groupsUnlocked: Bool = false 21 | 22 | @EnvironmentObject private var trackController: TrackController 23 | 24 | @Binding var showing: Bool 25 | 26 | @State private var selection: Track? 27 | @State private var showingPresets = false 28 | 29 | private var tracks: [Track] { 30 | trackController.fetchedResultsController.fetchedObjects ?? [] 31 | } 32 | 33 | private var shouldShowArchivedTracksLink: Bool { 34 | !(trackController.archivedTracksFRC.fetchedObjects ?? []).isEmpty 35 | } 36 | 37 | //MARK: Body 38 | 39 | var body: some View { 40 | Form { 41 | Section(footer: Text( 42 | groupsUnlocked 43 | ? "Swipe left and right on the main screen to change group" 44 | : "Unlock the groups upgrade from the settings page")) { 45 | NavigationLink("Groups", destination: GroupsView()) 46 | } 47 | .disabled(!groupsUnlocked) 48 | 49 | ForEach(tracks) { track in 50 | TrackCell(track: track, selection: $selection) 51 | } 52 | .onDelete(perform: delete) 53 | .onMove(perform: move) 54 | .animation(.easeInOut(duration: 0.25)) 55 | 56 | Section { 57 | Button("Create new track") { 58 | withAnimation { 59 | let newTrack = trackController.newTrack(index: (tracks.last?.index ?? -1) + 1, context: moc) 60 | select(track: newTrack, delay: 0.25) 61 | } 62 | } 63 | #if os(iOS) 64 | .foregroundColor(.accentColor) 65 | #endif 66 | 67 | Button("Add preset track") { 68 | showingPresets = true 69 | } 70 | #if os(iOS) 71 | .foregroundColor(.accentColor) 72 | #endif 73 | } 74 | 75 | if shouldShowArchivedTracksLink { 76 | Section { 77 | NavigationLink("Archive") { 78 | ArchivedTracksView() 79 | } 80 | } 81 | } 82 | } 83 | .environment(\.editMode, $editMode) 84 | .navigationTitle("Tracks") 85 | .toolbar { 86 | ToolbarItem(placement: .primaryAction) { 87 | StateEditButton(editMode: $editMode) 88 | } 89 | ToolbarItem(placement: .cancellationAction) { 90 | if !editMode.isEditing { 91 | Button("Done") { 92 | showing = false 93 | } 94 | } 95 | } 96 | } 97 | .sheet(isPresented: $showingPresets) { 98 | NavigationView { 99 | PresetTracksView { trackRepresentation in 100 | showingPresets = false 101 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { 102 | withAnimation { 103 | let newTrack = trackController.newTrack(from: trackRepresentation, index: (tracks.last?.index ?? -1) + 1, context: moc) 104 | select(track: newTrack, delay: 0.25) 105 | } 106 | } 107 | } 108 | .toolbar { 109 | ToolbarItem(placement: .cancellationAction) { 110 | Button("Cancel") { 111 | showingPresets = false 112 | } 113 | } 114 | } 115 | } 116 | .navigationViewStyle(StackNavigationViewStyle()) 117 | } 118 | } 119 | 120 | //MARK: Functions 121 | 122 | private func delete(_ indexSet: IndexSet) { 123 | indexSet.map { tracks[$0] }.forEach { 124 | trackController.delete(track: $0, context: moc) 125 | } 126 | trackController.scheduleSave() 127 | trackController.scheduleTimelineRefresh() 128 | } 129 | 130 | private func move(_ indices: IndexSet, newOffset: Int) { 131 | var trackIndices = tracks.enumerated().map { $0.offset } 132 | trackIndices.move(fromOffsets: indices, toOffset: newOffset) 133 | trackIndices.enumerated().compactMap { offset, element in 134 | element != offset ? (tracks[element], Int16(offset)) : nil 135 | }.forEach { $0.index = $1 } 136 | 137 | PersistenceController.save(context: moc) 138 | trackController.scheduleTimelineRefresh() 139 | } 140 | 141 | private func select(track: Track, delay: Double) { 142 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 143 | selection = track 144 | } 145 | } 146 | } 147 | 148 | //MARK: TrackCell 149 | 150 | struct TrackCell: View { 151 | 152 | @Environment(\.managedObjectContext) private var moc 153 | 154 | @ObservedObject var track: Track 155 | @Binding var selection: Track? 156 | var shouldShowToggle = true 157 | 158 | private var caption: String { 159 | [track.multiple ? "Multiple" : nil, track.reversed ? "Reversed" : nil].compactMap { $0 }.joined(separator: ", ") 160 | } 161 | 162 | private var background: some View { 163 | Color(rgb: Int(track.color)) 164 | .cornerRadius(8) 165 | } 166 | 167 | var body: some View { 168 | NavigationLink( 169 | destination: TrackView(track: track, selection: $selection, sheet: false), 170 | tag: track, 171 | selection: $selection) { 172 | HStack { 173 | if let systemImage = track.systemImage { 174 | Image(systemName: systemImage) 175 | .resizable() 176 | .aspectRatio(contentMode: .fit) 177 | .frame(width: 40, height: 40) 178 | .padding() 179 | .background(background) 180 | .foregroundColor(track.lightText ? .white : .black) 181 | } 182 | TextWithCaption(text: track.name ?? "", caption: caption) 183 | Spacer(minLength: 0) 184 | 185 | if shouldShowToggle { 186 | Toggle("Enabled", isOn: $track.enabled) 187 | .labelsHidden() 188 | .onChange(of: track.enabled) { _ in 189 | PersistenceController.save(context: moc) 190 | } 191 | } 192 | } 193 | } 194 | .foregroundColor(track.enabled ? .primary : .secondary) 195 | } 196 | } 197 | 198 | //MARK: Previews 199 | 200 | struct TracksView_Previews: PreviewProvider { 201 | static var previews: some View { 202 | NavigationView { 203 | TracksView(showing: .constant(true)) 204 | } 205 | .navigationViewStyle(StackNavigationViewStyle()) 206 | .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) 207 | .environmentObject(TrackController(preview: true)) 208 | .environmentObject(GroupController(preview: true)) 209 | .environmentObject(ViewControllerContainer()) 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /Tickmate/TicksWidget/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 | -------------------------------------------------------------------------------- /Tickmate/TicksWidget/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Tickmate/TicksWidget/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Tickmate/TicksWidget/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 | -------------------------------------------------------------------------------- /Tickmate/TicksWidget/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | TicksWidget 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSExtension 24 | 25 | NSExtensionPointIdentifier 26 | com.apple.widgetkit-extension 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Tickmate/TicksWidgetExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | production 7 | com.apple.developer.icloud-container-identifiers 8 | 9 | iCloud.vc.isv.Tickmate 10 | 11 | com.apple.developer.icloud-services 12 | 13 | CloudKit 14 | 15 | com.apple.security.application-groups 16 | 17 | group.vc.isv.Tickmate 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Tickmate/TicksWidgetIntentsExtension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | TicksWidgetIntentsExtension 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSExtension 24 | 25 | NSExtensionAttributes 26 | 27 | IntentsRestrictedWhileLocked 28 | 29 | IntentsRestrictedWhileProtectedDataUnavailable 30 | 31 | IntentsSupported 32 | 33 | ConfigurationIntent 34 | 35 | 36 | NSExtensionPointIdentifier 37 | com.apple.intents-service 38 | NSExtensionPrincipalClass 39 | $(PRODUCT_MODULE_NAME).IntentHandler 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /Tickmate/TicksWidgetIntentsExtension/IntentHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IntentHandler.swift 3 | // TicksWidgetIntentsExtension 4 | // 5 | // Created by Isaac Lyons on 6/24/21. 6 | // 7 | 8 | import Intents 9 | import CoreData 10 | 11 | class IntentHandler: INExtension, ConfigurationIntentHandling { 12 | 13 | override func handler(for intent: INIntent) -> Any { 14 | // This is the default implementation. If you want different objects to handle different intents, 15 | // you can override this and return the handler you want for that particular intent. 16 | 17 | return self 18 | } 19 | 20 | func provideTracksOptionsCollection(for intent: ConfigurationIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { 21 | let fetchRequest: NSFetchRequest = Track.fetchRequest() 22 | fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Track.index, ascending: true)] 23 | // If dynamic widgets are added, `enabled == YES` could maybe be removed 24 | // in case there are Tracks you want _only_ displayed in the widget. 25 | fetchRequest.predicate = NSPredicate(format: "enabled == YES AND isArchived == NO") 26 | 27 | do { 28 | let context = PersistenceController.shared.container.viewContext 29 | let tracks: [TrackItem] = try context.fetch(fetchRequest).compactMap { track in 30 | let id = track.objectID.uriRepresentation().absoluteString 31 | return TrackItem(identifier: id, display: track.name ??? "New Track") 32 | } 33 | completion(INObjectCollection(items: tracks), nil) 34 | } catch { 35 | completion(nil, error) 36 | } 37 | } 38 | 39 | func provideGroupOptionsCollection(for intent: ConfigurationIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { 40 | let fetchRequest: NSFetchRequest = TrackGroup.fetchRequest() 41 | fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \TrackGroup.index, ascending: true)] 42 | 43 | do { 44 | let context = PersistenceController.shared.container.viewContext 45 | let groups: [GroupItem] = try context.fetch(fetchRequest).compactMap { group in 46 | let id = group.objectID.uriRepresentation().absoluteString 47 | return GroupItem(identifier: id, display: group.displayName) 48 | } 49 | completion(INObjectCollection(items: groups), nil) 50 | } catch { 51 | completion(nil, error) 52 | } 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /Tickmate/TicksWidgetIntentsExtension/TicksWidgetIntentsExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.vc.isv.Tickmate 8 | 9 | 10 | 11 | --------------------------------------------------------------------------------