20 Commits

Author SHA1 Message Date
Zhigang Fang
0ff62e7df1 Add 15 min 2017-11-28 23:53:53 +08:00
Zhigang Fang
c84b8956c0 Not stealing the focus 2017-11-28 23:51:30 +08:00
Zhigang Fang
95986feec0 Keep in break duration 2017-11-28 10:22:09 +08:00
Zhigang Fang
31715af50a Bump version 2017-11-28 01:38:12 +08:00
Zhigang Fang
eeedf2dc6a Fix sleep 2017-11-28 01:37:50 +08:00
Zhigang Fang
ec8f1c8b16 Disable reminder when app is in the background 2017-11-28 01:34:30 +08:00
Zhigang Fang
fbf68cb025 Update interval 2017-11-28 01:22:30 +08:00
Zhigang Fang
9684f2c8a7 Add reminder and auto apply 2017-11-28 01:21:29 +08:00
Zhigang Fang
060d9550d4 Fix enter key 2017-11-27 23:45:37 +08:00
Zhigang Fang
a449c6c635 Sort project by most recently used 2017-11-27 23:41:30 +08:00
Zhigang Fang
4fda478078 Bump version 2017-10-31 11:04:25 +08:00
Zhigang Fang
0f7546836a Make project optional 2017-10-31 11:02:28 +08:00
Zhigang Fang
4f9efc4fd0 Update README.md 2017-10-28 01:43:35 -05:00
Zhigang Fang
2c04f58fb4 Bump version 2017-10-28 14:39:33 +08:00
Zhigang Fang
8c25c735d0 Renamed to in app 2017-10-28 09:18:22 +08:00
Zhigang Fang
947ab18de6 Add icon and category 2017-10-27 20:39:29 +08:00
Zhigang Fang
b97e3fdec5 Add alert to create project if not match found 2017-10-27 11:46:10 +08:00
Zhigang Fang
d7c1df72c3 Typo 2017-10-26 17:46:15 +08:00
Zhigang Fang
428d2aead9 Add features 2017-10-26 17:45:03 +08:00
Zhigang Fang
85d9616029 Add readme 2017-10-26 17:27:22 +08:00
20 changed files with 384 additions and 87 deletions

16
CHANGELOG Normal file
View File

@@ -0,0 +1,16 @@
Changelog
---------------
### 1.1.1
* Not stealing user focus when showing reminder
* Adding 15 min as reminder interval
### 1.1.0
* In recent entries, sort project by usage.
* When showing recent entries, enter key autocomplete instead of start timer.
* Add option to reminder you at certain interval.
* Add option to auto apply yes on interval timeout.
* Disable reminder when computer went to sleep.
* Keep break duration in timer if it's less than 10 min

View File

@@ -16,7 +16,7 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
041E2BA81F9EF0370036687C /* FloatingToggl.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FloatingToggl.app; sourceTree = BUILT_PRODUCTS_DIR; };
041E2BA81F9EF0370036687C /* In Time.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "In Time.app"; sourceTree = BUILT_PRODUCTS_DIR; };
041E2BAB1F9EF0370036687C /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
041E2BAD1F9EF0370036687C /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = "<group>"; };
041E2BAF1F9EF0370036687C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -24,7 +24,7 @@
041E2BB41F9EF0370036687C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
041E2BB51F9EF0370036687C /* FloatingToggl.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FloatingToggl.entitlements; sourceTree = "<group>"; };
041E2BBB1F9EF3450036687C /* FloatingPannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPannel.swift; sourceTree = "<group>"; };
046DF6521FA0763900FB970F /* MyPlayground.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = MyPlayground.playground; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
04D507151FCC84360038E7E0 /* CHANGELOG */ = {isa = PBXFileReference; lastKnownFileType = text; path = CHANGELOG; sourceTree = "<group>"; };
60799DCC63FBAD0197655FF4 /* Pods-FloatingToggl.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FloatingToggl.release.xcconfig"; path = "Pods/Target Support Files/Pods-FloatingToggl/Pods-FloatingToggl.release.xcconfig"; sourceTree = "<group>"; };
6A61B7517328FB18AF3456FF /* Pods-FloatingToggl.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FloatingToggl.debug.xcconfig"; path = "Pods/Target Support Files/Pods-FloatingToggl/Pods-FloatingToggl.debug.xcconfig"; sourceTree = "<group>"; };
B3256CA209129AD71D5691BF /* Pods_FloatingToggl.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FloatingToggl.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -45,7 +45,7 @@
041E2B9F1F9EF0370036687C = {
isa = PBXGroup;
children = (
046DF6521FA0763900FB970F /* MyPlayground.playground */,
04D507151FCC84360038E7E0 /* CHANGELOG */,
041E2BAA1F9EF0370036687C /* FloatingToggl */,
041E2BA91F9EF0370036687C /* Products */,
AB897BA953C7D862BCC1F2D0 /* Pods */,
@@ -56,7 +56,7 @@
041E2BA91F9EF0370036687C /* Products */ = {
isa = PBXGroup;
children = (
041E2BA81F9EF0370036687C /* FloatingToggl.app */,
041E2BA81F9EF0370036687C /* In Time.app */,
);
name = Products;
sourceTree = "<group>";
@@ -112,7 +112,7 @@
);
name = FloatingToggl;
productName = FloatingToggl;
productReference = 041E2BA81F9EF0370036687C /* FloatingToggl.app */;
productReference = 041E2BA81F9EF0370036687C /* In Time.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
@@ -363,7 +363,7 @@
INFOPLIST_FILE = FloatingToggl/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
PRODUCT_BUNDLE_IDENTIFIER = com.matrix.FloatingToggl;
PRODUCT_NAME = "$(TARGET_NAME)";
PRODUCT_NAME = "In Time";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 4.0;
};
@@ -382,7 +382,7 @@
INFOPLIST_FILE = FloatingToggl/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
PRODUCT_BUNDLE_IDENTIFIER = com.matrix.FloatingToggl;
PRODUCT_NAME = "$(TARGET_NAME)";
PRODUCT_NAME = "In Time";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 4.0;
};

View File

@@ -9,17 +9,98 @@
import Cocoa
import KeychainSwift
extension UserDefaults {
var reminderInterval: Int {
get { return UserDefaults.standard.integer(forKey: "com.floatingtoggl.reminder") }
set { UserDefaults.standard.set(newValue, forKey: "com.floatingtoggl.reminder") }
}
var shouldAutoApply: Bool {
get { return UserDefaults.standard.bool(forKey: "com.floatingtoggl.autoapply") }
set { UserDefaults.standard.set(newValue, forKey: "com.floatingtoggl.autoapply") }
}
}
extension Notification.Name {
static let reminderIntervalUpdated: Notification.Name = Notification.Name("com.floatingtoggl.reminderintervalupdated")
}
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
var reminderInterval: Int = 0 {
didSet {
fiveMinuteReminder.isChecked = reminderInterval == 5
fifteenMinuteReminder.isChecked = reminderInterval == 15
thirtyMinuteReminder.isChecked = reminderInterval == 30
noReminder.isChecked = reminderInterval == 0
if reminderInterval != oldValue {
UserDefaults.standard.reminderInterval = reminderInterval
NotificationCenter.default.post(name: .reminderIntervalUpdated, object: nil)
}
}
}
@IBOutlet weak var fiveMinuteReminder: NSMenuItem!
@IBOutlet weak var fifteenMinuteReminder: NSMenuItem!
@IBOutlet weak var thirtyMinuteReminder: NSMenuItem!
@IBOutlet weak var noReminder: NSMenuItem!
@IBAction func reminder5minTapped(_ sender: NSMenuItem) {
reminderInterval = 5
}
@IBAction func reminder15minTapped(_ sender: NSMenuItem) {
reminderInterval = 15
}
@IBAction func reminder30minTapped(_ sender: NSMenuItem) {
reminderInterval = 30
}
@IBAction func reminderNoneTapped(_ sender: NSMenuItem) {
reminderInterval = 0
}
var shouldAutoApply: Bool = false {
didSet {
autoApply.isChecked = shouldAutoApply
if shouldAutoApply != oldValue {
UserDefaults.standard.shouldAutoApply = shouldAutoApply
}
}
}
@IBOutlet weak var autoApply: NSMenuItem!
@IBAction func autoApplyTapped(_ sender: NSMenuItem) {
shouldAutoApply = !shouldAutoApply
}
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Insert code here to initialize your application
reminderInterval = UserDefaults.standard.reminderInterval
shouldAutoApply = UserDefaults.standard.shouldAutoApply
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}
}
extension NSMenuItem {
var isChecked: Bool {
get { return state ~= .on }
set { state = newValue ? .on : .off }
}
}

View File

@@ -1,53 +1,63 @@
{
"images" : [
{
"idiom" : "mac",
"size" : "16x16",
"idiom" : "mac",
"filename" : "Icon-16.png",
"scale" : "1x"
},
{
"idiom" : "mac",
"size" : "16x16",
"idiom" : "mac",
"filename" : "Icon-32.png",
"scale" : "2x"
},
{
"idiom" : "mac",
"size" : "32x32",
"idiom" : "mac",
"filename" : "Icon-33.png",
"scale" : "1x"
},
{
"idiom" : "mac",
"size" : "32x32",
"idiom" : "mac",
"filename" : "Icon-64.png",
"scale" : "2x"
},
{
"idiom" : "mac",
"size" : "128x128",
"idiom" : "mac",
"filename" : "Icon-128.png",
"scale" : "1x"
},
{
"idiom" : "mac",
"size" : "128x128",
"idiom" : "mac",
"filename" : "Icon-256.png",
"scale" : "2x"
},
{
"idiom" : "mac",
"size" : "256x256",
"idiom" : "mac",
"filename" : "Icon-257.png",
"scale" : "1x"
},
{
"idiom" : "mac",
"size" : "256x256",
"idiom" : "mac",
"filename" : "Icon-512.png",
"scale" : "2x"
},
{
"idiom" : "mac",
"size" : "512x512",
"idiom" : "mac",
"filename" : "Icon-513.png",
"scale" : "1x"
},
{
"idiom" : "mac",
"size" : "512x512",
"idiom" : "mac",
"filename" : "Icon-1024.png",
"scale" : "2x"
}
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="13196" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" initialViewController="B8D-0N-5wS">
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="13529" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" initialViewController="B8D-0N-5wS">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="13196"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="13529"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
<capability name="stacking Non-gravity area distributions on NSStackView" minToolsVersion="7.0" minSystemVersion="10.11"/>
</dependencies>
@@ -13,11 +13,11 @@
<application id="hnw-xV-0zn" sceneMemberID="viewController">
<menu key="mainMenu" title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
<items>
<menuItem title="FloatingToggl" id="1Xt-HY-uBw">
<menuItem title="In Time" id="1Xt-HY-uBw">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="FloatingToggl" systemMenu="apple" id="uQy-DD-JDr">
<menu key="submenu" title="In Time" systemMenu="apple" id="uQy-DD-JDr">
<items>
<menuItem title="About FloatingToggl" id="5kV-Vb-QxS">
<menuItem title="About In Time" id="5kV-Vb-QxS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="orderFrontStandardAboutPanel:" target="Ady-hI-5gd" id="Exp-CZ-Vem"/>
@@ -31,7 +31,7 @@
<menu key="submenu" title="Services" systemMenu="services" id="hz9-B4-Xy5"/>
</menuItem>
<menuItem isSeparatorItem="YES" id="4je-JR-u6R"/>
<menuItem title="Hide FloatingToggl" keyEquivalent="h" id="Olw-nP-bQN">
<menuItem title="Hide In Time" keyEquivalent="h" id="Olw-nP-bQN">
<connections>
<action selector="hide:" target="Ady-hI-5gd" id="PnN-Uc-m68"/>
</connections>
@@ -49,7 +49,7 @@
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="kCx-OE-vgT"/>
<menuItem title="Quit FloatingToggl" keyEquivalent="q" id="4sb-4s-VLi">
<menuItem title="Quit In Time" keyEquivalent="q" id="4sb-4s-VLi">
<connections>
<action selector="terminate:" target="Ady-hI-5gd" id="Te7-pn-YzF"/>
</connections>
@@ -293,26 +293,40 @@
</items>
</menu>
</menuItem>
<menuItem title="Window" id="aUF-d1-5bR">
<menuItem title="Reminder" id="aUF-d1-5bR">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Window" systemMenu="window" id="Td7-aD-5lo">
<menu key="submenu" title="Reminder" systemMenu="window" id="Td7-aD-5lo">
<items>
<menuItem title="Minimize" keyEquivalent="m" id="OY7-WF-poV">
<connections>
<action selector="performMiniaturize:" target="Ady-hI-5gd" id="VwT-WD-YPe"/>
</connections>
</menuItem>
<menuItem title="Zoom" id="R4o-n2-Eq4">
<menuItem title="5 min" id="OY7-WF-poV">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="performZoom:" target="Ady-hI-5gd" id="DIl-cC-cCs"/>
<action selector="reminder5minTapped:" target="Voe-Tx-rLC" id="Wyl-dO-evG"/>
</connections>
</menuItem>
<menuItem title="15 min" id="31U-4c-cuC">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="reminder15minTapped:" target="Voe-Tx-rLC" id="0KX-yg-Vht"/>
</connections>
</menuItem>
<menuItem title="30 min" id="R4o-n2-Eq4">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="reminder30minTapped:" target="Voe-Tx-rLC" id="vJT-TE-vap"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="eu3-7i-yIM"/>
<menuItem title="Bring All to Front" id="LE2-aR-0XJ">
<menuItem title="None" state="on" id="LE2-aR-0XJ">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="arrangeInFront:" target="Ady-hI-5gd" id="DRN-fu-gQh"/>
<action selector="reminderNoneTapped:" target="Voe-Tx-rLC" id="wVc-dE-AmQ"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="3Tu-fH-Lk1"/>
<menuItem title="Auto Apply" id="ho0-J6-R08">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="autoApplyTapped:" target="Voe-Tx-rLC" id="Vax-TS-cKp"/>
</connections>
</menuItem>
</items>
@@ -322,7 +336,7 @@
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Help" systemMenu="help" id="F2S-fz-NVQ">
<items>
<menuItem title="FloatingToggl Help" keyEquivalent="?" id="FKE-Sm-Kum">
<menuItem title="In Time Help" keyEquivalent="?" id="FKE-Sm-Kum">
<connections>
<action selector="showHelp:" target="Ady-hI-5gd" id="y7X-2Q-9no"/>
</connections>
@@ -336,7 +350,15 @@
<outlet property="delegate" destination="Voe-Tx-rLC" id="PrD-fu-P6m"/>
</connections>
</application>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="FloatingToggl" customModuleProvider="target"/>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="In_Time" customModuleProvider="target">
<connections>
<outlet property="autoApply" destination="ho0-J6-R08" id="tBW-iT-pGt"/>
<outlet property="fifteenMinuteReminder" destination="31U-4c-cuC" id="AFh-yY-wvS"/>
<outlet property="fiveMinuteReminder" destination="OY7-WF-poV" id="tab-3t-lh3"/>
<outlet property="noReminder" destination="LE2-aR-0XJ" id="73T-RG-g3y"/>
<outlet property="thirtyMinuteReminder" destination="R4o-n2-Eq4" id="eOc-0v-Xgl"/>
</connections>
</customObject>
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
<customObject id="Ady-hI-5gd" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
</objects>
@@ -345,10 +367,10 @@
<!--Window Controller-->
<scene sceneID="R2V-B0-nI4">
<objects>
<windowController showSeguePresentationStyle="single" id="B8D-0N-5wS" customClass="FloatingPannel" customModule="FloatingToggl" customModuleProvider="target" sceneMemberID="viewController">
<windowController showSeguePresentationStyle="single" id="B8D-0N-5wS" customClass="FloatingPannel" customModule="In_Time" customModuleProvider="target" sceneMemberID="viewController">
<window key="window" title="Window" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" oneShot="NO" releasedWhenClosed="NO" showsToolbarButton="NO" animationBehavior="default" id="IQv-IB-iLA" customClass="NSPanel">
<windowStyleMask key="styleMask" titled="YES" resizable="YES" utility="YES" nonactivatingPanel="YES" HUD="YES" fullSizeContentView="YES"/>
<windowCollectionBehavior key="collectionBehavior" moveToActiveSpace="YES"/>
<windowCollectionBehavior key="collectionBehavior" moveToActiveSpace="YES" fullScreenNone="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="95" y="73" width="426" height="100"/>
<rect key="screenRect" x="0.0" y="0.0" width="1680" height="1027"/>
@@ -367,7 +389,7 @@
<!--View Controller-->
<scene sceneID="hIz-AP-VOD">
<objects>
<viewController id="XfG-lQ-9wD" customClass="ViewController" customModule="FloatingToggl" customModuleProvider="target" sceneMemberID="viewController">
<viewController id="XfG-lQ-9wD" customClass="ViewController" customModule="In_Time" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" wantsLayer="YES" id="m2S-Jp-Qdl">
<rect key="frame" x="0.0" y="0.0" width="364" height="60"/>
<autoresizingMask key="autoresizingMask"/>
@@ -375,7 +397,7 @@
<stackView distribution="fill" orientation="horizontal" alignment="centerY" spacing="20" horizontalStackHuggingPriority="249.99998474121094" verticalStackHuggingPriority="249.99998474121094" detachesHiddenViews="YES" translatesAutoresizingMaskIntoConstraints="NO" id="wu7-dI-dhr">
<rect key="frame" x="20" y="0.0" width="324" height="60"/>
<subviews>
<textField horizontalHuggingPriority="249" verticalHuggingPriority="750" horizontalCompressionResistancePriority="999" allowsCharacterPickerTouchBarItem="YES" translatesAutoresizingMaskIntoConstraints="NO" id="BPy-6c-wmT" customClass="AutoGrowTextField" customModule="FloatingToggl" customModuleProvider="target">
<textField horizontalHuggingPriority="249" verticalHuggingPriority="750" horizontalCompressionResistancePriority="999" allowsCharacterPickerTouchBarItem="YES" translatesAutoresizingMaskIntoConstraints="NO" id="BPy-6c-wmT" customClass="AutoGrowTextField" customModule="In_Time" customModuleProvider="target">
<rect key="frame" x="-2" y="20.5" width="197" height="19"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="100" id="Tug-Tb-Szc"/>

View File

@@ -17,9 +17,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>1.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>6</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>

View File

@@ -14,7 +14,7 @@ import KeychainSwift
struct TimeEntry: Decodable {
let id: Int64
let start: String
let start: Date
let description: String?
}
@@ -25,14 +25,15 @@ struct DataResponse<T: Decodable>: Decodable {
struct Project: Decodable {
let id: Int64
let name: String
let at: Date
}
struct User: Decodable {
let id: Int64
let fullname: String
let projects: [Project]
let time_entries: [TimeEntry]
let projects: [Project]?
let time_entries: [TimeEntry]?
}
@@ -44,6 +45,36 @@ private extension URL {
}
extension JSONEncoder {
static var toggle: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return encoder
}
}
extension JSONDecoder {
static var toggle: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}
}
extension ISO8601DateFormatter {
static var toggle: ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.timeZone = .current
return formatter
}
}
struct Endpoint<T: Decodable> {
let method: String
let url: URL
@@ -66,7 +97,7 @@ struct Endpoint<T: Decodable> {
request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: [])
}
return URLSession.shared.rx.data(request: request).map({ data in
(try JSONDecoder().decode(DataResponse<T>.self, from: data)).data
(try JSONDecoder.toggle.decode(DataResponse<T>.self, from: data)).data
})
})
}
@@ -80,7 +111,7 @@ struct Endpoint<T: Decodable> {
"time_entry": [
"description": title,
"pid": projectId ?? NSNull(),
"created_with": "Toggl Bar"
"created_with": "In Time"
] as NSDictionary
])
}
@@ -89,6 +120,23 @@ struct Endpoint<T: Decodable> {
return Endpoint<TimeEntry>(method: "PUT", url: URL.api("time_entries/\(timeEntry)/stop"))
}
static func create(project: String) -> Endpoint<Project> {
return Endpoint<Project>.init(method: "POST", url: URL.api("projects"), body: [
"project": [
"name": project,
"is_private": true
]
] as NSDictionary)
}
static func update(timeEntry: Int64, duration: Int) -> Endpoint<TimeEntry> {
return Endpoint<TimeEntry>(method: "POST", url: URL.api("time_entries/\(timeEntry)"), body: [
"time_entry": [
"duration": duration
]
])
}
}
@@ -105,6 +153,7 @@ class TogglViewModel {
let input = Variable<String>("")
let active = Variable<Bool>(NSApplication.shared.isActive)
let awake = Variable<Bool>(true)
private let disposeBag = DisposeBag()
@@ -114,8 +163,8 @@ class TogglViewModel {
var completions: Driver<[String]> {
return user.asDriver().map({ user -> [String] in
guard let user = user else { return [] }
let projects = user.projects.map({"#\($0.name)"})
let entries = user.time_entries.sorted(by: {$0.start > $1.start}).flatMap({$0.description})
let projects = user.projects?.sorted(by: {$0.at > $1.at}).map({"#\($0.name)"}) ?? []
let entries = user.time_entries?.sorted(by: {$0.start > $1.start}).flatMap({$0.description}) ?? []
return Array(NSOrderedSet(array: projects + entries)).flatMap({$0 as? String})
}).flatMapLatest({[weak self] (completion:[String]) -> Driver<[String]> in
guard let input = self?.input else { return .just(completion) }
@@ -163,7 +212,7 @@ class TogglViewModel {
})
}
return Driver<TimeEntry?>.just(nil)
}).debug().drive(current).disposed(by: self.disposeBag)
}).drive(current).disposed(by: self.disposeBag)
token.asDriver().flatMapLatest({[weak self] token -> Driver<User?> in
if let token = token {
@@ -172,55 +221,109 @@ class TogglViewModel {
}) ?? .just(nil)
}
return Driver<User?>.just(nil)
}).debug().drive(user).disposed(by: self.disposeBag)
}).drive(user).disposed(by: self.disposeBag)
NSWorkspace.shared.notificationCenter
.rx.notification(NSWorkspace.screensDidSleepNotification)
.subscribe(onNext: {[weak self] _ in
self?.awake.value = false
guard let entry = self?.current.value else { return }
self?.timeEntryWhenSlept = entry
self?.screenSleptAt = Date()
self?.stopTimer()
}).disposed(by: self.disposeBag)
NSWorkspace.shared.notificationCenter
.rx.notification(NSWorkspace.screensDidWakeNotification)
.subscribe(onNext: {[weak self] _ in
self?.awake.value = true
defer {
self?.screenSleptAt = nil
self?.timeEntryWhenSlept = nil
}
guard
let date = self?.screenSleptAt,
let timer = self?.timeEntryWhenSlept,
Date().timeIntervalSince(date) < 60 * 10
Date().timeIntervalSince(date) > 60 * 10
else {
self?.screenSleptAt = nil
self?.timeEntryWhenSlept = nil
return
}
self?.input.value = timer.description ?? ""
self?.startTimer()
self?.stopTimer(entry: timer, stoppedAt: date)
}).disposed(by: self.disposeBag)
}
var presentReminder: Observable<()> {
return Observable.merge([
self.awake.asObservable().distinctUntilChanged().map({_ in ()}),
NotificationCenter.default.rx.notification(.reminderIntervalUpdated).map({_ in ()}),
self.input.asObservable().distinctUntilChanged().map({_ in ()})
]).flatMapLatest({[weak self] _ -> Observable<()> in
if self?.awake.value != true { return .empty() }
let interval = UserDefaults.standard.reminderInterval
if interval == 0 { return .empty() }
return Observable<Int>.interval(RxTimeInterval(interval * 60), scheduler: MainScheduler.asyncInstance).map({ _ in ()})
})
}
func startTimer() {
guard let token = self.token.value else { return }
let projectId = self.input.value.hashKey.flatMap({ projectName in
self.user.value?.projects.first(where: {
let inputValue = input.value
if let projectName = inputValue.hashKey {
if let existingProject = self.user.value?.projects?.first(where: {
$0.name.lowercased() == projectName.lowercased()
})
})?.id
}) {
self.startTimer(input: inputValue, projectId: existingProject.id)
} else {
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = "Project \(projectName) does not exist, should I create it?"
alert.addButton(withTitle: "Create")
alert.addButton(withTitle: "Cancel")
alert.beginSheetModal(for: NSApplication.shared.keyWindow!) {[weak self] (response) in
guard response == .alertFirstButtonReturn else {
self?.startTimer(input: inputValue, projectId: nil)
return
}
_ = self?.createProject(name: projectName).subscribe(onNext: {[weak self] project in
self?.startTimer(input: inputValue, projectId: project.id)
}, onError: { _ in
self?.startTimer(input: inputValue, projectId: nil)
})
}
}
} else {
self.startTimer(input: inputValue, projectId: nil)
}
}
func createProject(name: String) -> Observable<Project> {
guard let token = self.token.value else { return .empty() }
return Endpoint<Project>.create(project: name).request(with: token)
}
func startTimer(input: String, projectId: Int64?) {
guard let token = self.token.value else { return }
Endpoint<TimeEntry>.start(title: self.input.value, projectId: projectId)
.request(with: token)
.map(Optional.some)
.catchErrorJustReturn(nil)
.bind(to: current)
.bind(to: self.current)
.disposed(by: self.disposeBag)
}
func stopTimer() {
func stopTimer(entry: TimeEntry? = nil, stoppedAt: Date? = nil) {
guard let token = self.token.value else { return }
guard let entryId = self.current.value?.id else { return }
Endpoint<TimeEntry>.stop(timeEntry: entryId).request(with: token)
guard let entry = entry ?? self.current.value else { return }
Endpoint<TimeEntry>.stop(timeEntry: entry.id).request(with: token)
.flatMap({ entry -> Observable<TimeEntry> in
if let stopped = stoppedAt {
let duration = Int(stopped.timeIntervalSince(entry.start))
return Endpoint<TimeEntry>.update(timeEntry: entry.id, duration: duration).request(with: token)
}
return .just(entry)
})
.map({_ in nil})
.catchErrorJustReturn(nil)
.bind(to: current)
@@ -322,11 +425,8 @@ class ViewController: NSViewController {
self.selectedRow = 0
}).disposed(by: self.disposeBag)
let df = DateFormatter()
df.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
viewModel.current.asDriver().flatMapLatest({ entry -> Driver<String> in
guard let start = entry?.start, let date = df.date(from: start) else { return .empty() }
guard let date = entry?.start else { return .empty() }
return Driver<Int>.interval(1).startWith(1).map({ _ in
let time: Int = Int(Date().timeIntervalSince(date))
let hours = time / 3600
@@ -354,6 +454,10 @@ class ViewController: NSViewController {
self?.placeCursorAtTheEnd()
self?.resizeWindow()
}).disposed(by: self.disposeBag)
viewModel.presentReminder.subscribe(onNext: {[weak self] in
self?.presentReminder()
}).disposed(by: disposeBag)
}
var trackingRect: NSView.TrackingRectTag?
@@ -431,6 +535,52 @@ class ViewController: NSViewController {
tokenField.becomeFirstResponder()
}
func presentDontForget() {
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = "Don't forget to track your time"
alert.beginSheetModal(for: self.view.window!, completionHandler: nil)
alert.window.resignKey()
}
func presentReminder() {
guard let current = viewModel.current.value else {
presentDontForget()
return
}
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = "Are you still working on \n \(current.description ?? "Untitled")"
let button = alert.addButton(withTitle: "YES")
var disposable: Disposable?
if UserDefaults.standard.shouldAutoApply {
let autoApplyInterval = 60
disposable = Observable<Int>.interval(1, scheduler: MainScheduler.asyncInstance)
.map({autoApplyInterval - $0})
.subscribe(onNext: {[weak self] countdown in
if countdown > 0 {
button.title = "YES (\(countdown))"
} else if countdown == 0 {
self?.view.window?.endSheet(alert.window)
}
})
button.title = "YES (\(autoApplyInterval))"
}
alert.addButton(withTitle: "No")
alert.beginSheetModal(for: self.view.window!) { (response) in
disposable?.dispose()
if response == .alertSecondButtonReturn {
self.viewModel.stopTimer()
self.inputLabel.becomeFirstResponder()
}
}
alert.window.resignKey()
}
@IBAction func setToken(_ sender: NSMenuItem) {
presentSetToken()
}
@@ -487,7 +637,11 @@ extension ViewController: NSTextFieldDelegate {
case #selector(textView.insertTab(_:)):
insertSelection()
case #selector(textView.insertNewline(_:)):
self.viewModel.startTimer()
if isShowingRecentEntries {
insertSelection()
} else {
self.viewModel.startTimer()
}
default:
return false
}

View File

@@ -1,10 +0,0 @@
//: Playground - noun: a place where people can play
import RxCocoa
import RxSwift
import PlaygroundSupport
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
f.date(from: "2017-10-25T08:01:26Z")

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<playground version='5.0' target-platform='macos'>
<timeline fileName='timeline.xctimeline'/>
</playground>

26
README.md Normal file
View File

@@ -0,0 +1,26 @@
Toggl Bar
---------
A Toggl Bar Mac app that will follow you wherever you go.
First you need to obtain your API token from:
https://www.toggl.com/app/profile
Download this app from https://github.com/zhigang1992/InTime/releases/tag/1.0.0
And you're all set.
### Features
1. Using `#project` syntax to add task to a project.
1. Auto complete using your projects and recent entries.
1. Automatically stop timer when screen goes to sleep.
1. Automatically resume timer when screen goes back on within 10 min.
1. ...
![Screen Shot 2017-10-26 at 5.23.39 PM.png](https://i.loli.net/2017/10/26/59f1a9c4ad8d7.png)
![Screen Shot 2017-10-26 at 5.23.27 PM.png](https://i.loli.net/2017/10/26/59f1a9c4cbe11.png)