diff --git a/apps/mobile/README.md b/apps/mobile/README.md index e9e5fd8..ad8b7aa 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -15,3 +15,20 @@ A few resources to get you started if this is your first Flutter project: For help getting started with Flutter development, view the [online documentation](https://docs.flutter.dev/), which offers tutorials, samples, guidance on mobile development, and a full API reference. + +## Home screen widget and fast-end reminder + +- **Widget (iOS)**: `ios/DropItWidget` is a WidgetKit extension (small + medium) showing today's + calories (consumed / target) and the fasting timer (live countdown, or the scheduled window when + no fast is running). The app writes the data via `lib/widget_sync.dart` (`home_widget`) into the + App Group `group.dev.yarinlevi.dropit` every time `syncReminders()` runs (launch, resume, fast + start/stop, add/delete entry, settings change). +- **One-time Apple setup**: enable the *App Groups* capability with `group.dev.yarinlevi.dropit` for + both the `dev.yarinlevi.dropit` and `dev.yarinlevi.dropit.DropItWidget` App IDs in the developer + portal (entitlements files are already in the repo). Then long-press the home screen > + > Drop It. +- **Android**: this repo has no `android/` project yet. When it is added, create an + `AppWidgetProvider` named `DropItWidgetProvider` + layout, register the `` in the + manifest, and read the same keys that `widget_sync.dart` saves (`consumed`, `goal`, `fastRunning`, + `fastEndsAt`, `fastStartHour`, `fastEndHour`, ...). See the `home_widget` package docs. +- **Fast-end notification**: Settings > "Notify me when my fast ends" (default off, stored locally in + `prefs.json`). Scheduled for `endsAt` when a fast is running, cancelled on stop or when toggled off. diff --git a/apps/mobile/ios/DropItWidget/DropItWidget.entitlements b/apps/mobile/ios/DropItWidget/DropItWidget.entitlements new file mode 100644 index 0000000..b9e0883 --- /dev/null +++ b/apps/mobile/ios/DropItWidget/DropItWidget.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.dev.yarinlevi.dropit + + + diff --git a/apps/mobile/ios/DropItWidget/DropItWidget.swift b/apps/mobile/ios/DropItWidget/DropItWidget.swift new file mode 100644 index 0000000..d332798 --- /dev/null +++ b/apps/mobile/ios/DropItWidget/DropItWidget.swift @@ -0,0 +1,205 @@ +import SwiftUI +import WidgetKit + +// Shared with the app through the App Group; keys are written by lib/widget_sync.dart. +private let appGroupId = "group.dev.yarinlevi.dropit" + +struct DropItEntry: TimelineEntry { + let date: Date + let signedIn: Bool + let consumed: Int + let goal: Int + let fastEnabled: Bool + let fastRunning: Bool + let fastEndsAt: Date? + let fastStartHour: Int + let fastEndHour: Int + let rtl: Bool + let lblCalories: String + let lblKcalLeft: String + let lblKcalOver: String + let lblFastTitle: String + let lblFastLeft: String + let lblFastDone: String + let lblFastOff: String + let lblFastWindow: String + let lblSignedOut: String + + static func load(at date: Date = Date()) -> DropItEntry { + let d = UserDefaults(suiteName: appGroupId) + let endsRaw = d?.string(forKey: "fastEndsAt") ?? "" + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let plain = ISO8601DateFormatter() + let ends = iso.date(from: endsRaw) ?? plain.date(from: endsRaw) + func s(_ key: String, _ fallback: String) -> String { + let v = d?.string(forKey: key) ?? "" + return v.isEmpty ? fallback : v + } + return DropItEntry( + date: date, + signedIn: d?.bool(forKey: "signedIn") ?? false, + consumed: d?.integer(forKey: "consumed") ?? 0, + goal: d?.integer(forKey: "goal") ?? 0, + fastEnabled: d?.bool(forKey: "fastEnabled") ?? false, + fastRunning: d?.bool(forKey: "fastRunning") ?? false, + fastEndsAt: ends, + fastStartHour: d?.object(forKey: "fastStartHour") as? Int ?? -1, + fastEndHour: d?.object(forKey: "fastEndHour") as? Int ?? -1, + rtl: d?.bool(forKey: "rtl") ?? false, + lblCalories: s("lblCalories", "Calories"), + lblKcalLeft: s("lblKcalLeft", "kcal left"), + lblKcalOver: s("lblKcalOver", "kcal over"), + lblFastTitle: s("lblFastTitle", "Fasting"), + lblFastLeft: s("lblFastLeft", "left"), + lblFastDone: s("lblFastDone", "Fast complete"), + lblFastOff: s("lblFastOff", "Fasting off"), + lblFastWindow: s("lblFastWindow", "Fasting window"), + lblSignedOut: s("lblSignedOut", "Open Drop It to sign in") + ) + } +} + +struct DropItProvider: TimelineProvider { + func placeholder(in context: Context) -> DropItEntry { DropItEntry.load() } + + func getSnapshot(in context: Context, completion: @escaping (DropItEntry) -> Void) { + completion(DropItEntry.load()) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = DropItEntry.load() + // Refresh when the fast ends, otherwise let the app push updates (hourly fallback). + var next = Date().addingTimeInterval(60 * 60) + if let ends = entry.fastEndsAt, entry.fastRunning, ends > Date(), ends < next { + next = ends + } + completion(Timeline(entries: [entry], policy: .after(next))) + } +} + +struct DropItWidgetView: View { + @Environment(\.widgetFamily) var family + let entry: DropItEntry + + private var remaining: Int { entry.goal - entry.consumed } + private var over: Bool { entry.consumed > entry.goal && entry.goal > 0 } + private var progress: Double { + entry.goal > 0 ? min(Double(entry.consumed) / Double(entry.goal), 1) : 0 + } + + private func hourText(_ hour: Int) -> String { + String(format: "%02d:00", hour % 24) + } + + var body: some View { + Group { + if !entry.signedIn { + Text(entry.lblSignedOut) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } else if family == .systemMedium { + HStack(alignment: .center, spacing: 16) { + calories + Divider() + fasting + } + } else { + VStack(alignment: .leading, spacing: 12) { + calories + fasting + } + } + } + .environment(\.layoutDirection, entry.rtl ? .rightToLeft : .leftToRight) + .widgetBackground() + } + + private var calories: some View { + VStack(alignment: .leading, spacing: 4) { + Text(entry.lblCalories) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + Text("\(entry.consumed)") + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundColor(Color(.systemOrange)) + + Text(" / \(entry.goal)") + .font(.footnote) + .foregroundColor(Color(.secondaryLabel)) + ProgressView(value: progress) + .tint(over ? Color(.systemRed) : Color(.systemOrange)) + Text("\(abs(remaining)) \(over ? entry.lblKcalOver : entry.lblKcalLeft)") + .font(.caption2) + .foregroundStyle(over ? Color(.systemRed) : .secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var fasting: some View { + VStack(alignment: .leading, spacing: 4) { + Text(entry.lblFastTitle) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + if !entry.fastEnabled { + Text(entry.lblFastOff) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + } else if entry.fastRunning, let ends = entry.fastEndsAt { + if ends > Date() { + Text(timerInterval: Date()...ends, countsDown: true) + .font(.system(size: 24, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color(.systemBlue)) + Text(entry.lblFastLeft) + .font(.caption2) + .foregroundStyle(.secondary) + } else { + Text(entry.lblFastDone) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color(.systemGreen)) + } + } else { + Text(entry.lblFastWindow) + .font(.caption2) + .foregroundStyle(.secondary) + if entry.fastStartHour >= 0 && entry.fastEndHour >= 0 { + Text("\(hourText(entry.fastStartHour)) – \(hourText(entry.fastEndHour))") + .font(.subheadline.weight(.semibold)) + .monospacedDigit() + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +extension View { + @ViewBuilder + func widgetBackground() -> some View { + if #available(iOS 17.0, *) { + containerBackground(for: .widget) { Color(.secondarySystemGroupedBackground) } + } else { + padding().background(Color(.secondarySystemGroupedBackground)) + } + } +} + +struct DropItWidget: Widget { + let kind = "DropItWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: DropItProvider()) { entry in + DropItWidgetView(entry: entry) + } + .configurationDisplayName("Drop It") + .description("Today's calories and your fasting timer.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +@main +struct DropItWidgetBundle: WidgetBundle { + var body: some Widget { DropItWidget() } +} diff --git a/apps/mobile/ios/DropItWidget/Info.plist b/apps/mobile/ios/DropItWidget/Info.plist new file mode 100644 index 0000000..0ea698d --- /dev/null +++ b/apps/mobile/ios/DropItWidget/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Drop It + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/apps/mobile/ios/Runner.xcodeproj/project.pbxproj b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj index ebb608d..31d7664 100644 --- a/apps/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -8,9 +8,13 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 1888AA64DF299780292A5DF6 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B05B035CBBC4C33917EDBE0D /* Foundation.framework */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 48AF2EBB92383C966ECC0752 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BBB5E74E38A2828106F85F93 /* SwiftUI.framework */; }; + 4CEFB2076ADEA17A0007CF99 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D5CE39CEF631759D51282CF3 /* WidgetKit.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 749FEB243C0B3A98F2E29741 /* DropItWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E61B6CEAA62C1DDE29C22D /* DropItWidget.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 8B46958EDA16368FC19D3678 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AE298FB8FAB88F4AF511BA51 /* Pods_Runner.framework */; }; @@ -19,6 +23,7 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; A1B2C3D4E5F60718293A4B02 /* HealthSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B01 /* HealthSync.swift */; }; E00B80397A5232A92AE589B3 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A9AFA32EE7C99683FA6C23C8 /* Pods_RunnerTests.framework */; }; + FEF15B22522489111691358E /* DropItWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3B0FE002BD270DA05D89E39A /* DropItWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -29,6 +34,13 @@ remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; + F6FF567BD2AF02CB250EB12C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 008B7C184E6F86EB0EB7F635; + remoteInfo = DropItWidget; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -42,6 +54,17 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + FB3F3B297F449A11EDFEE5F5 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + FEF15B22522489111691358E /* DropItWidget.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -51,7 +74,9 @@ 2261C44CFEEED0D6001D85EA /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B0FE002BD270DA05D89E39A /* DropItWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DropItWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 461D3E3E8280B332C5E6E63C /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7589C768B45D244E7DABA8D7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; @@ -70,11 +95,26 @@ A1B2C3D4E5F60718293A4B03 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; A9AFA32EE7C99683FA6C23C8 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AE298FB8FAB88F4AF511BA51 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B05B035CBBC4C33917EDBE0D /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; B203297172C88EE5B59976ED /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + BBB5E74E38A2828106F85F93 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/SwiftUI.framework; sourceTree = DEVELOPER_DIR; }; + BFA0D24D2523A35D12EAAC40 /* DropItWidget.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = DropItWidget.entitlements; sourceTree = ""; }; + C3E61B6CEAA62C1DDE29C22D /* DropItWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DropItWidget.swift; sourceTree = ""; }; + D5CE39CEF631759D51282CF3 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/WidgetKit.framework; sourceTree = DEVELOPER_DIR; }; D64FF2D8902175D57A653C85 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 21142132A9123FC142E9F97A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1888AA64DF299780292A5DF6 /* Foundation.framework in Frameworks */, + 4CEFB2076ADEA17A0007CF99 /* WidgetKit.framework in Frameworks */, + 48AF2EBB92383C966ECC0752 /* SwiftUI.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 895DC9EF541F28F5D7BAA2FD /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -95,6 +135,17 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 317C745F36753A0F0CAD487F /* DropItWidget */ = { + isa = PBXGroup; + children = ( + C3E61B6CEAA62C1DDE29C22D /* DropItWidget.swift */, + 461D3E3E8280B332C5E6E63C /* Info.plist */, + BFA0D24D2523A35D12EAAC40 /* DropItWidget.entitlements */, + ); + name = DropItWidget; + path = DropItWidget; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -103,6 +154,16 @@ path = RunnerTests; sourceTree = ""; }; + 915486D5430F8DB44033D541 /* iOS */ = { + isa = PBXGroup; + children = ( + B05B035CBBC4C33917EDBE0D /* Foundation.framework */, + D5CE39CEF631759D51282CF3 /* WidgetKit.framework */, + BBB5E74E38A2828106F85F93 /* SwiftUI.framework */, + ); + name = iOS; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -124,6 +185,7 @@ 331C8082294A63A400263BE5 /* RunnerTests */, A588A91D7DF2A82A63E12027 /* Pods */, F66567ACF3C8EAE9DB410434 /* Frameworks */, + 317C745F36753A0F0CAD487F /* DropItWidget */, ); sourceTree = ""; }; @@ -132,6 +194,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + 3B0FE002BD270DA05D89E39A /* DropItWidget.appex */, ); name = Products; sourceTree = ""; @@ -173,6 +236,7 @@ children = ( AE298FB8FAB88F4AF511BA51 /* Pods_Runner.framework */, A9AFA32EE7C99683FA6C23C8 /* Pods_RunnerTests.framework */, + 915486D5430F8DB44033D541 /* iOS */, ); name = Frameworks; sourceTree = ""; @@ -180,6 +244,23 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 008B7C184E6F86EB0EB7F635 /* DropItWidget */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4C4A65DE57A8ADEA685EE71E /* Build configuration list for PBXNativeTarget "DropItWidget" */; + buildPhases = ( + 460A69F52B83B65F9224323A /* Sources */, + 21142132A9123FC142E9F97A /* Frameworks */, + 27BB85BFE1AAD30891097057 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = DropItWidget; + productName = DropItWidget; + productReference = 3B0FE002BD270DA05D89E39A /* DropItWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; 331C8080294A63A400263BE5 /* RunnerTests */ = { isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; @@ -209,12 +290,14 @@ 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, + FB3F3B297F449A11EDFEE5F5 /* Embed Foundation Extensions */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, A6BB47B2072DC4E50C5944EE /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( + 74C8CE6483A1B56DAB9EBF1C /* PBXTargetDependency */, ); name = Runner; packageProductDependencies = ( @@ -262,11 +345,19 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + 008B7C184E6F86EB0EB7F635 /* DropItWidget */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 27BB85BFE1AAD30891097057 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807F294A63A400263BE5 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -391,6 +482,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 460A69F52B83B65F9224323A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 749FEB243C0B3A98F2E29741 /* DropItWidget.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -410,6 +509,12 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; + 74C8CE6483A1B56DAB9EBF1C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = DropItWidget; + target = 008B7C184E6F86EB0EB7F635 /* DropItWidget */; + targetProxy = F6FF567BD2AF02CB250EB12C /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -558,6 +663,59 @@ }; name = Profile; }; + 43F5E4E68A33C7BEECDAE7CD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = DropItWidget/DropItWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = C3NBA2W32P; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = DropItWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.yarinlevi.dropit.DropItWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 6D69E811A5DCF0FB0C0DB332 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = DropItWidget/DropItWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = C3NBA2W32P; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = DropItWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.yarinlevi.dropit.DropItWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -718,6 +876,33 @@ }; name = Release; }; + A6359036C4EE467B58080F44 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = DropItWidget/DropItWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = C3NBA2W32P; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = DropItWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.yarinlevi.dropit.DropItWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -731,6 +916,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 4C4A65DE57A8ADEA685EE71E /* Build configuration list for PBXNativeTarget "DropItWidget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6D69E811A5DCF0FB0C0DB332 /* Release */, + 43F5E4E68A33C7BEECDAE7CD /* Debug */, + A6359036C4EE467B58080F44 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/apps/mobile/ios/Runner/Runner.entitlements b/apps/mobile/ios/Runner/Runner.entitlements index dab226c..0102e3e 100644 --- a/apps/mobile/ios/Runner/Runner.entitlements +++ b/apps/mobile/ios/Runner/Runner.entitlements @@ -8,5 +8,9 @@ com.apple.developer.healthkit.background-delivery + com.apple.security.application-groups + + group.dev.yarinlevi.dropit + diff --git a/apps/mobile/lib/api.dart b/apps/mobile/lib/api.dart index aa46a27..06ca1ea 100644 --- a/apps/mobile/lib/api.dart +++ b/apps/mobile/lib/api.dart @@ -95,6 +95,33 @@ class Api { } catch (_) {} } + Future _prefsFile() async { + final dir = await getApplicationSupportDirectory(); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return File('${dir.path}/prefs.json'); + } + + /// Local-only toggle: notify when a fast ends. Defaults to off. + Future loadFastEndReminder() async { + try { + final file = await _prefsFile(); + if (await file.exists()) { + final data = jsonDecode(await file.readAsString()) as Map; + return data['fastEndReminder'] == true; + } + } catch (_) {} + return false; + } + + Future saveFastEndReminder(bool value) async { + try { + final file = await _prefsFile(); + await file.writeAsString(jsonEncode({'fastEndReminder': value}), flush: true); + } catch (_) {} + } + Future loadToken() async { try { token = await _storage.read(key: 'token'); diff --git a/apps/mobile/lib/copy.dart b/apps/mobile/lib/copy.dart index ff04523..a82a6b2 100644 --- a/apps/mobile/lib/copy.dart +++ b/apps/mobile/lib/copy.dart @@ -331,6 +331,29 @@ class Copy { ); String get reminderDone => pick('Your fast timer is complete', 'טיימר הצום הסתיים'); + String get fastEndReminder => + pick('Notify me when my fast ends', 'הודע לי כשהצום מסתיים'); + String get fastEndReminderHint => pick( + 'A notification when your fasting timer completes', + 'התראה כשטיימר הצום מסתיים', + ); + String get fastEndedNotification => pick( + 'Your fast is complete. Time to eat!', + 'הצום הסתיים. הגיע הזמן לאכול!', + ); + String get notificationsDenied => pick( + 'Notifications are turned off for Drop It in iOS Settings.', + 'ההתראות כבויות עבור Drop It בהגדרות iOS.', + ); + String get widgetCalories => pick('Calories', 'קלוריות'); + String get widgetKcalLeft => pick('kcal left', 'קק״ל נותרו'); + String get widgetKcalOver => pick('kcal over', 'קק״ל מעל'); + String get widgetFastLeft => pick('left', 'נותרו'); + String get widgetFastDone => pick('Fast complete', 'הצום הסתיים'); + String get widgetFastOff => pick('Fasting off', 'צום כבוי'); + String get widgetFastWindow => pick('Fasting window', 'חלון צום'); + String get widgetFastTitle => pick('Fasting', 'צום'); + String get widgetSignedOut => pick('Open Drop It to sign in', 'פתח את Drop It להתחברות'); String get previousWeek => pick('Previous week', 'השבוע הקודם'); String get nextWeek => pick('Next week', 'השבוע הבא'); String get sedentary => pick('Sedentary', 'יושבני'); diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart index dc62a8a..b086714 100644 --- a/apps/mobile/lib/main.dart +++ b/apps/mobile/lib/main.dart @@ -19,6 +19,7 @@ import 'nutrition_parser.dart'; import 'nutrition_scan.dart'; import 'reminders.dart'; import 'theme.dart'; +import 'widget_sync.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -36,6 +37,7 @@ class _DropItAppState extends State with WidgetsBindingObserver { final Api api = Api(); late final AppleHealth health = AppleHealth(api); final Reminders reminders = Reminders(); + final WidgetSync widgetSync = WidgetSync(); Map? me; bool loading = true; String lang = 'en'; @@ -82,6 +84,7 @@ class _DropItAppState extends State with WidgetsBindingObserver { try { await reminders.init(); } catch (_) {} + await widgetSync.init(); await api.loadToken(); if (api.token != null) { final cachedMe = await api.loadCachedMe(); @@ -109,9 +112,14 @@ class _DropItAppState extends State with WidgetsBindingObserver { } Future syncReminders() async { - if (api.token == null) return; + if (api.token == null) { + await widgetSync.update(t: t, signedIn: false); + await reminders.cancelFastEnd(); + return; + } + Map? fasting; try { - final fasting = await api.send('GET', '/api/fasting'); + fasting = await api.send('GET', '/api/fasting'); final notifications = ((fasting['notifications'] as List?) ?? const []) .whereType() @@ -128,6 +136,38 @@ class _DropItAppState extends State with WidgetsBindingObserver { ), ); } catch (_) {} + try { + await _syncFastEnd(fasting); + } catch (_) {} + Map? day; + try { + day = await api.send('GET', '/api/days/today'); + } catch (_) {} + // Keep the last known values when the network is unavailable. + if (fasting != null || day != null) { + await widgetSync.update( + t: t, + signedIn: true, + fasting: fasting, + day: day, + ); + } + } + + /// Schedules (or cancels) the optional "fast ended" notification. + Future _syncFastEnd(Map? fasting) async { + if (fasting == null) return; + final endsAt = fasting['endsAt'] as String?; + if (!await api.loadFastEndReminder() || + fasting['running'] != true || + endsAt == null) { + await reminders.cancelFastEnd(); + return; + } + await reminders.scheduleFastEnd( + endsAt: DateTime.parse(endsAt), + body: t.fastEndedNotification, + ); } Future afterAuth(Map auth) async { @@ -202,10 +242,12 @@ class _DropItAppState extends State with WidgetsBindingObserver { } catch (_) {} await AppleHealth.stopLocal(); await api.setToken(null); + await syncReminders(); setState(() => me = null); }, health: health, sync: syncReminders, + requestNotifications: reminders.requestPermission, ), ), ), @@ -846,6 +888,7 @@ class HomeScreen extends StatefulWidget { required this.onLogout, required this.health, required this.sync, + required this.requestNotifications, }); final Api api; final Copy t; @@ -856,6 +899,7 @@ class HomeScreen extends StatefulWidget { final Future Function() onLogout; final AppleHealth health; final Future Function() sync; + final Future Function() requestNotifications; @override State createState() => _HomeScreenState(); @@ -932,6 +976,7 @@ class _HomeScreenState extends State { onMe: widget.onMe, health: widget.health, sync: widget.sync, + requestNotifications: widget.requestNotifications, onLogout: widget.onLogout, isAdmin: isAdmin, ), @@ -1032,6 +1077,7 @@ class _TodayScreenState extends State { } hiddenEntries.remove(id); if (mounted) await load(); + await widget.sync(); }); } @@ -2770,6 +2816,7 @@ class SettingsScreen extends StatefulWidget { required this.sync, required this.onLogout, this.isAdmin = false, + required this.requestNotifications, }); final Api api; final Copy t; @@ -2781,6 +2828,7 @@ class SettingsScreen extends StatefulWidget { final Future Function() sync; final Future Function() onLogout; final bool isAdmin; + final Future Function() requestNotifications; @override State createState() => _SettingsScreenState(); @@ -2801,6 +2849,8 @@ class _SettingsScreenState extends State { String healthError = ''; bool healthBusy = false; bool autoPublish = false; + bool fastEndReminder = false; + bool notificationsDenied = false; List get _permissions => (widget.me['permissions'] as List?) ?? const []; bool get _canReadHealth => _permissions.contains('appleHealth:read'); @@ -2993,6 +3043,9 @@ class _SettingsScreenState extends State { void initState() { super.initState(); autoPublish = widget.me['user']['autoPublishFoods'] == true; + unawaited(widget.api.loadFastEndReminder().then((value) { + if (mounted) setState(() => fastEndReminder = value); + })); unawaited(load()); unawaited(loadHealth()); } @@ -3349,6 +3402,33 @@ class _SettingsScreenState extends State { ); }, ), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: Text(t.fastEndReminder), + subtitle: Text( + notificationsDenied + ? t.notificationsDenied + : t.fastEndReminderHint, + style: TextStyle( + color: notificationsDenied + ? apple.accentDangerText + : apple.labelSecondary, + fontSize: 13, + ), + ), + value: fastEndReminder, + onChanged: (value) async { + var granted = true; + if (value) granted = await widget.requestNotifications(); + await widget.api.saveFastEndReminder(value && granted); + if (!mounted) return; + setState(() { + notificationsDenied = value && !granted; + fastEndReminder = value && granted; + }); + await widget.sync(); + }, + ), ], ), ), diff --git a/apps/mobile/lib/reminders.dart b/apps/mobile/lib/reminders.dart index 715a3ab..6006c22 100644 --- a/apps/mobile/lib/reminders.dart +++ b/apps/mobile/lib/reminders.dart @@ -19,7 +19,9 @@ class ReminderCopy { } class Reminders { - final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin(); + final FlutterLocalNotificationsPlugin _plugin = + FlutterLocalNotificationsPlugin(); + static const int _fastEndId = 100; bool _ready = false; Future init() async { @@ -39,7 +41,10 @@ class Reminders { required ReminderCopy copy, }) async { if (!_ready) return; - await _plugin.cancelAll(); + // Only clear the window reminders (ids 1..99); the fast-end id is managed separately. + for (var i = 1; i < _fastEndId; i++) { + await _plugin.cancel(id: i); + } final now = tz.TZDateTime.now(tz.local); var id = 1; for (final item in notifications) { @@ -56,12 +61,51 @@ class Reminders { title: 'Drop It', body: body, scheduledDate: zoned, - notificationDetails: const NotificationDetails(iOS: DarwinNotificationDetails()), + notificationDetails: const NotificationDetails( + iOS: DarwinNotificationDetails(), + ), androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, ); } } + /// Asks for notification permission; returns whether it was granted. + Future requestPermission() async { + if (!_ready) return false; + final ios = _plugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >(); + final granted = await ios?.requestPermissions(alert: true, sound: true); + return granted ?? false; + } + + /// Schedules a one-off notification for when the running fast ends. + Future scheduleFastEnd({ + required DateTime endsAt, + required String body, + }) async { + if (!_ready) return; + await _plugin.cancel(id: _fastEndId); + final zoned = tz.TZDateTime.from(endsAt.toLocal(), tz.local); + if (!zoned.isAfter(tz.TZDateTime.now(tz.local))) return; + await _plugin.zonedSchedule( + id: _fastEndId, + title: 'Drop It', + body: body, + scheduledDate: zoned, + notificationDetails: const NotificationDetails( + iOS: DarwinNotificationDetails(), + ), + androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + ); + } + + Future cancelFastEnd() async { + if (!_ready) return; + await _plugin.cancel(id: _fastEndId); + } + String? _bodyFor(String kind, Object? leadMinutes, ReminderCopy copy) { switch (kind) { case 'lead': diff --git a/apps/mobile/lib/widget_sync.dart b/apps/mobile/lib/widget_sync.dart new file mode 100644 index 0000000..b04ca24 --- /dev/null +++ b/apps/mobile/lib/widget_sync.dart @@ -0,0 +1,56 @@ +import 'package:home_widget/home_widget.dart'; + +import 'copy.dart'; + +/// Pushes today's calories and the fasting state to the iOS home screen widget +/// through the shared App Group, then asks WidgetKit to reload the timeline. +class WidgetSync { + static const String appGroupId = 'group.dev.yarinlevi.dropit'; + static const String iosWidgetName = 'DropItWidget'; + + bool _ready = false; + + Future init() async { + try { + await HomeWidget.setAppGroupId(appGroupId); + _ready = true; + } catch (_) {} + } + + Future update({ + required Copy t, + required bool signedIn, + Map? fasting, + Map? day, + }) async { + if (!_ready) return; + try { + final totals = (day?['totals'] as Map?)?.cast(); + final values = { + 'signedIn': signedIn, + 'rtl': t.he, + 'consumed': ((totals?['calories'] as num?) ?? 0).round(), + 'goal': ((day?['goalKcal'] as num?) ?? 0).round(), + 'fastEnabled': fasting?['enabled'] == true, + 'fastRunning': fasting?['running'] == true, + 'fastEndsAt': fasting?['endsAt'] as String? ?? '', + 'fastStartHour': (fasting?['startHour'] as num?)?.toInt() ?? -1, + 'fastEndHour': (fasting?['endHour'] as num?)?.toInt() ?? -1, + 'fastLengthHours': (fasting?['lengthHours'] as num?)?.toInt() ?? 0, + 'lblCalories': t.widgetCalories, + 'lblKcalLeft': t.widgetKcalLeft, + 'lblKcalOver': t.widgetKcalOver, + 'lblFastTitle': t.widgetFastTitle, + 'lblFastLeft': t.widgetFastLeft, + 'lblFastDone': t.widgetFastDone, + 'lblFastOff': t.widgetFastOff, + 'lblFastWindow': t.widgetFastWindow, + 'lblSignedOut': t.widgetSignedOut, + }; + for (final entry in values.entries) { + await HomeWidget.saveWidgetData(entry.key, entry.value); + } + await HomeWidget.updateWidget(iOSName: iosWidgetName); + } catch (_) {} + } +} diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index daa4e15..053b8ec 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -279,6 +279,14 @@ packages: description: flutter source: sdk version: "0.0.0" + home_widget: + dependency: "direct main" + description: + name: home_widget + sha256: "1dbfdfbb3b47de24639adfba352050d85d66af112b9cc3ec5bf7627a7813abcb" + url: "https://pub.dev" + source: hosted + version: "0.10.0" hooks: dependency: transitive description: diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index 539371f..b7276cc 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: path: packages/flutter_tesseract_ocr image_picker: ^1.2.4 image: ^4.12.0 + home_widget: ^0.10.0 dev_dependencies: flutter_test: