Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<receiver>` 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.
10 changes: 10 additions & 0 deletions apps/mobile/ios/DropItWidget/DropItWidget.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.dev.yarinlevi.dropit</string>
</array>
</dict>
</plist>
205 changes: 205 additions & 0 deletions apps/mobile/ios/DropItWidget/DropItWidget.swift
Original file line number Diff line number Diff line change
@@ -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<DropItEntry>) -> 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() }
}
29 changes: 29 additions & 0 deletions apps/mobile/ios/DropItWidget/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Drop It</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
Loading
Loading