feat: major platform expansion — Brain service, RSS reader, iOS app, AI assistants, Firefox extension
Brain Service: - Playwright stealth crawler replacing browserless (og:image, Readability, Reddit JSON API) - AI classification with tag definitions and folder assignment - YouTube video download via yt-dlp - Karakeep migration complete (96 items) - Taxonomy management (folders with icons/colors, tags) - Discovery shuffle, sort options, search (Meilisearch + pgvector) - Item tag/folder editing, card color accents RSS Reader Service: - Custom FastAPI reader replacing Miniflux - Feed management (add/delete/refresh), category support - Full article extraction via Readability - Background content fetching for new entries - Mark all read with confirmation - Infinite scroll, retention cleanup (30/60 day) - 17 feeds migrated from Miniflux iOS App (SwiftUI): - Native iOS 17+ app with @Observable architecture - Cookie-based auth, configurable gateway URL - Dashboard with custom background photo + frosted glass widgets - Full fitness module (today/templates/goals/food library) - AI assistant chat (fitness + brain, raw JSON state management) - 120fps ProMotion support AI Assistants (Gateway): - Unified dispatcher with fitness/brain domain detection - Fitness: natural language food logging, photo analysis, multi-item splitting - Brain: save/append/update/delete notes, search & answer, undo support - Madiha user gets fitness-only (brain disabled) Firefox Extension: - One-click save to Brain from any page - Login with platform credentials - Right-click context menu (save page/link/image) - Notes field for URL saves - Signed and published on AMO Other: - Reader bookmark button routes to Brain (was Karakeep) - Fitness food library with "Add" button + add-to-meal popup - Kindle send file size check (25MB SMTP2GO limit) - Atelier UI as default (useAtelierShell=true) - Mobile upload box in nav drawer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.078",
|
||||
"green" : "0.412",
|
||||
"red" : "0.545"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
ios/Platform/Platform/Assets.xcassets/Contents.json
Normal file
6
ios/Platform/Platform/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
5
ios/Platform/Platform/Config.swift
Normal file
5
ios/Platform/Platform/Config.swift
Normal file
@@ -0,0 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
enum Config {
|
||||
static let gatewayURL = "https://dash.quadjourney.com"
|
||||
}
|
||||
43
ios/Platform/Platform/ContentView.swift
Normal file
43
ios/Platform/Platform/ContentView.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(AuthManager.self) private var authManager
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if authManager.isCheckingAuth {
|
||||
ProgressView("Loading...")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.canvas)
|
||||
} else if authManager.isLoggedIn {
|
||||
MainTabView()
|
||||
} else {
|
||||
LoginView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await authManager.checkAuth()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MainTabView: View {
|
||||
@State private var selectedTab = 0
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
HomeView()
|
||||
.tabItem {
|
||||
Label("Home", systemImage: "house.fill")
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
FitnessTabView()
|
||||
.tabItem {
|
||||
Label("Fitness", systemImage: "figure.run")
|
||||
}
|
||||
.tag(1)
|
||||
}
|
||||
.tint(.accent)
|
||||
}
|
||||
}
|
||||
145
ios/Platform/Platform/Core/APIClient.swift
Normal file
145
ios/Platform/Platform/Core/APIClient.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
|
||||
enum APIError: LocalizedError {
|
||||
case invalidURL
|
||||
case httpError(Int, String?)
|
||||
case decodingError(Error)
|
||||
case networkError(Error)
|
||||
case unknown(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "Invalid URL"
|
||||
case .httpError(let code, let msg): return msg ?? "HTTP error \(code)"
|
||||
case .decodingError(let err): return "Decoding error: \(err.localizedDescription)"
|
||||
case .networkError(let err): return err.localizedDescription
|
||||
case .unknown(let msg): return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class APIClient {
|
||||
static let shared = APIClient()
|
||||
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
private init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.httpCookieAcceptPolicy = .always
|
||||
config.httpShouldSetCookies = true
|
||||
config.httpCookieStorage = .shared
|
||||
session = URLSession(configuration: config)
|
||||
decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
}
|
||||
|
||||
private func buildURL(_ path: String) throws -> URL {
|
||||
guard let url = URL(string: "\(Config.gatewayURL)\(path)") else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
func request<T: Decodable>(_ method: String, _ path: String, body: Encodable? = nil) async throws -> T {
|
||||
let url = try buildURL(path)
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
if let body = body {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
req.httpBody = try encoder.encode(body)
|
||||
}
|
||||
|
||||
let (data, response): (Data, URLResponse)
|
||||
do {
|
||||
(data, response) = try await session.data(for: req)
|
||||
} catch {
|
||||
throw APIError.networkError(error)
|
||||
}
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
!(200...299).contains(httpResponse.statusCode) {
|
||||
let bodyStr = String(data: data, encoding: .utf8)
|
||||
throw APIError.httpError(httpResponse.statusCode, bodyStr)
|
||||
}
|
||||
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
func get<T: Decodable>(_ path: String) async throws -> T {
|
||||
try await request("GET", path)
|
||||
}
|
||||
|
||||
func post<T: Decodable>(_ path: String, body: Encodable? = nil) async throws -> T {
|
||||
try await request("POST", path, body: body)
|
||||
}
|
||||
|
||||
func patch<T: Decodable>(_ path: String, body: Encodable? = nil) async throws -> T {
|
||||
try await request("PATCH", path, body: body)
|
||||
}
|
||||
|
||||
func put<T: Decodable>(_ path: String, body: Encodable? = nil) async throws -> T {
|
||||
try await request("PUT", path, body: body)
|
||||
}
|
||||
|
||||
func delete(_ path: String) async throws {
|
||||
let url = try buildURL(path)
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "DELETE"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response): (Data, URLResponse)
|
||||
do {
|
||||
(data, response) = try await session.data(for: req)
|
||||
} catch {
|
||||
throw APIError.networkError(error)
|
||||
}
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
!(200...299).contains(httpResponse.statusCode) {
|
||||
let bodyStr = String(data: data, encoding: .utf8)
|
||||
throw APIError.httpError(httpResponse.statusCode, bodyStr)
|
||||
}
|
||||
}
|
||||
|
||||
func rawPost(_ path: String, body: Data) async throws -> (Data, URLResponse) {
|
||||
let url = try buildURL(path)
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
req.httpBody = body
|
||||
|
||||
let (data, response): (Data, URLResponse)
|
||||
do {
|
||||
(data, response) = try await session.data(for: req)
|
||||
} catch {
|
||||
throw APIError.networkError(error)
|
||||
}
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
!(200...299).contains(httpResponse.statusCode) {
|
||||
let bodyStr = String(data: data, encoding: .utf8)
|
||||
throw APIError.httpError(httpResponse.statusCode, bodyStr)
|
||||
}
|
||||
|
||||
return (data, response)
|
||||
}
|
||||
|
||||
func clearCookies() {
|
||||
if let cookies = HTTPCookieStorage.shared.cookies {
|
||||
for cookie in cookies {
|
||||
HTTPCookieStorage.shared.deleteCookie(cookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
104
ios/Platform/Platform/Core/AuthManager.swift
Normal file
104
ios/Platform/Platform/Core/AuthManager.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
|
||||
struct AuthUser: Codable {
|
||||
let id: Int
|
||||
let username: String
|
||||
let displayName: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username
|
||||
case displayName = "display_name"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// Handle id as Int or String
|
||||
if let intId = try? container.decode(Int.self, forKey: .id) {
|
||||
id = intId
|
||||
} else if let strId = try? container.decode(String.self, forKey: .id),
|
||||
let parsed = Int(strId) {
|
||||
id = parsed
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(Int.self, .init(codingPath: [CodingKeys.id], debugDescription: "Expected Int or String for id"))
|
||||
}
|
||||
username = try container.decode(String.self, forKey: .username)
|
||||
displayName = try container.decodeIfPresent(String.self, forKey: .displayName)
|
||||
}
|
||||
}
|
||||
|
||||
struct LoginRequest: Encodable {
|
||||
let username: String
|
||||
let password: String
|
||||
}
|
||||
|
||||
struct LoginResponse: Decodable {
|
||||
let success: Bool
|
||||
let user: AuthUser?
|
||||
}
|
||||
|
||||
struct MeResponse: Decodable {
|
||||
let authenticated: Bool
|
||||
let user: AuthUser?
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class AuthManager {
|
||||
var isLoggedIn = false
|
||||
var isCheckingAuth = true
|
||||
var user: AuthUser?
|
||||
var loginError: String?
|
||||
|
||||
private let api = APIClient.shared
|
||||
private let loggedInKey = "isLoggedIn"
|
||||
|
||||
init() {
|
||||
isLoggedIn = UserDefaults.standard.bool(forKey: loggedInKey)
|
||||
}
|
||||
|
||||
func checkAuth() async {
|
||||
guard UserDefaults.standard.bool(forKey: loggedInKey) else {
|
||||
isCheckingAuth = false
|
||||
isLoggedIn = false
|
||||
return
|
||||
}
|
||||
do {
|
||||
let response: MeResponse = try await api.get("/api/auth/me")
|
||||
if response.authenticated {
|
||||
user = response.user
|
||||
isLoggedIn = true
|
||||
} else {
|
||||
isLoggedIn = false
|
||||
UserDefaults.standard.set(false, forKey: loggedInKey)
|
||||
}
|
||||
} catch {
|
||||
isLoggedIn = false
|
||||
UserDefaults.standard.set(false, forKey: loggedInKey)
|
||||
}
|
||||
isCheckingAuth = false
|
||||
}
|
||||
|
||||
func login(username: String, password: String) async {
|
||||
loginError = nil
|
||||
do {
|
||||
let response: LoginResponse = try await api.post("/api/auth/login", body: LoginRequest(username: username, password: password))
|
||||
if response.success {
|
||||
user = response.user
|
||||
isLoggedIn = true
|
||||
UserDefaults.standard.set(true, forKey: loggedInKey)
|
||||
} else {
|
||||
loginError = "Invalid credentials"
|
||||
}
|
||||
} catch let error as APIError {
|
||||
loginError = error.localizedDescription
|
||||
} catch {
|
||||
loginError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func logout() {
|
||||
api.clearCookies()
|
||||
isLoggedIn = false
|
||||
user = nil
|
||||
UserDefaults.standard.set(false, forKey: loggedInKey)
|
||||
}
|
||||
}
|
||||
285
ios/Platform/Platform/Features/Assistant/AssistantChatView.swift
Normal file
285
ios/Platform/Platform/Features/Assistant/AssistantChatView.swift
Normal file
@@ -0,0 +1,285 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
enum AssistantTab: String, CaseIterable {
|
||||
case chat = "AI Chat"
|
||||
case quickAdd = "Quick Add"
|
||||
}
|
||||
|
||||
struct AssistantChatView: View {
|
||||
let entryDate: String
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@Environment(AuthManager.self) private var authManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var vm: AssistantViewModel
|
||||
@State private var selectedTab: AssistantTab = .chat
|
||||
@State private var scrollProxy: ScrollViewProxy?
|
||||
|
||||
init(entryDate: String, onDismiss: @escaping () -> Void) {
|
||||
self.entryDate = entryDate
|
||||
self.onDismiss = onDismiss
|
||||
_vm = State(initialValue: AssistantViewModel(entryDate: entryDate, username: nil))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Tab switcher
|
||||
HStack(spacing: 0) {
|
||||
ForEach(AssistantTab.allCases, id: \.self) { tab in
|
||||
Button {
|
||||
selectedTab = tab
|
||||
} label: {
|
||||
Text(tab.rawValue)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(selectedTab == tab ? Color.accent : Color.textSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(selectedTab == tab ? Color.accent.opacity(0.1) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
if selectedTab == .chat {
|
||||
chatContent
|
||||
} else {
|
||||
FoodSearchView(mealType: .snack, dateString: entryDate)
|
||||
}
|
||||
}
|
||||
.background(Color.canvas)
|
||||
.navigationTitle("Assistant")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
vm = AssistantViewModel(entryDate: entryDate, username: authManager.user?.username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chatContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Messages
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(vm.messages) { message in
|
||||
messageBubble(message)
|
||||
.id(message.id)
|
||||
}
|
||||
if vm.isLoading {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.tint(Color.accent)
|
||||
Text("Thinking...")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.id("loading")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.onAppear { scrollProxy = proxy }
|
||||
.onChange(of: vm.messages.count) { _, _ in
|
||||
withAnimation {
|
||||
proxy.scrollTo(vm.messages.last?.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let error = vm.error {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
// Input bar
|
||||
HStack(spacing: 8) {
|
||||
PhotosPicker(selection: Binding(
|
||||
get: { vm.selectedPhoto },
|
||||
set: { newVal in
|
||||
vm.selectedPhoto = newVal
|
||||
Task { await vm.loadPhoto(newVal) }
|
||||
}
|
||||
), matching: .images) {
|
||||
Image(systemName: vm.photoData != nil ? "photo.fill" : "photo")
|
||||
.font(.title3)
|
||||
.foregroundStyle(vm.photoData != nil ? Color.accent : Color.textSecondary)
|
||||
}
|
||||
|
||||
TextField("Ask anything...", text: $vm.inputText)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(10)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await vm.send()
|
||||
withAnimation {
|
||||
scrollProxy?.scrollTo(vm.messages.last?.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(vm.inputText.isEmpty && vm.photoData == nil ? Color.textTertiary : Color.accent)
|
||||
}
|
||||
.disabled(vm.inputText.isEmpty && vm.photoData == nil)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.surface)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func messageBubble(_ message: ChatMessage) -> some View {
|
||||
if message.role == "user" {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(message.content)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
.padding(12)
|
||||
.background(Color(hex: "8B6914").opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.frame(maxWidth: 280, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !message.content.isEmpty {
|
||||
Text(message.content)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
.padding(12)
|
||||
.background(Color.assistantBubble)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.frame(maxWidth: 300, alignment: .leading)
|
||||
}
|
||||
|
||||
// Draft cards
|
||||
ForEach(Array(message.drafts.enumerated()), id: \.offset) { _, draft in
|
||||
draftCard(draft, applied: message.applied)
|
||||
}
|
||||
|
||||
// Source links
|
||||
if !message.sources.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(message.sources) { source in
|
||||
if let url = URL(string: source.href), !source.href.isEmpty {
|
||||
Link(destination: url) {
|
||||
sourceChip(source)
|
||||
}
|
||||
} else {
|
||||
sourceChip(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
private func draftCard(_ draft: FitnessDraft, applied: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(draft.foodName)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Spacer()
|
||||
Text(draft.mealType.capitalized)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(Color.accent)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.accent.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
macroChip("Cal", Int(draft.calories))
|
||||
macroChip("P", Int(draft.protein))
|
||||
macroChip("C", Int(draft.carbs))
|
||||
macroChip("F", Int(draft.fat))
|
||||
}
|
||||
|
||||
if !applied {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await vm.applyDraft() }
|
||||
} label: {
|
||||
Text("Add it")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.emerald)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.emerald)
|
||||
Text("Added")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(Color.emerald)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
|
||||
.frame(maxWidth: 300, alignment: .leading)
|
||||
}
|
||||
|
||||
private func macroChip(_ label: String, _ value: Int) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text("\(value)")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text(label)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func sourceChip(_ source: SourceLink) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: source.type == "brain" ? "brain" : "link")
|
||||
.font(.system(size: 10))
|
||||
Text(source.title)
|
||||
.font(.caption2)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(Color.accent)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.accent.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
struct ChatMessage: Identifiable {
|
||||
let id = UUID()
|
||||
let role: String // "user" or "assistant"
|
||||
let content: String
|
||||
var drafts: [FitnessDraft] = []
|
||||
var sources: [SourceLink] = []
|
||||
var applied: Bool = false
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class AssistantViewModel {
|
||||
var messages: [ChatMessage] = []
|
||||
var inputText = ""
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var selectedPhoto: PhotosPickerItem?
|
||||
var photoData: Data?
|
||||
|
||||
// Raw JSON state from server — never decode this
|
||||
private var serverState: Any?
|
||||
private let api = APIClient.shared
|
||||
private var entryDate: String
|
||||
private var allowBrain: Bool
|
||||
|
||||
init(entryDate: String, username: String?) {
|
||||
self.entryDate = entryDate
|
||||
self.allowBrain = (username ?? "") != "madiha"
|
||||
}
|
||||
|
||||
func send(action: String = "chat") async {
|
||||
let text = inputText.trimmingCharacters(in: .whitespaces)
|
||||
guard !text.isEmpty || action == "apply" else { return }
|
||||
|
||||
if action == "chat" && !text.isEmpty {
|
||||
messages.append(ChatMessage(role: "user", content: text))
|
||||
inputText = ""
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
do {
|
||||
// Build request as raw JSON
|
||||
var requestDict: [String: Any] = [
|
||||
"entryDate": entryDate,
|
||||
"action": action,
|
||||
"allowBrain": allowBrain
|
||||
]
|
||||
|
||||
// Messages array
|
||||
let msgArray = messages.filter { $0.role == "user" }.map { msg -> [String: String] in
|
||||
["role": "user", "content": msg.content]
|
||||
}
|
||||
requestDict["messages"] = msgArray
|
||||
|
||||
// State pass-through
|
||||
if let state = serverState {
|
||||
requestDict["state"] = state
|
||||
} else {
|
||||
requestDict["state"] = NSNull()
|
||||
}
|
||||
|
||||
// Photo
|
||||
if let data = photoData {
|
||||
let base64 = data.base64EncodedString()
|
||||
requestDict["imageDataUrl"] = "data:image/jpeg;base64,\(base64)"
|
||||
photoData = nil
|
||||
} else {
|
||||
requestDict["imageDataUrl"] = NSNull()
|
||||
}
|
||||
|
||||
let bodyData = try JSONSerialization.data(withJSONObject: requestDict)
|
||||
let (responseData, _) = try await api.rawPost("/api/assistant/chat", body: bodyData)
|
||||
|
||||
// Parse response as raw JSON
|
||||
guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
|
||||
error = "Invalid response"
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
// Store raw state
|
||||
serverState = json["state"]
|
||||
|
||||
// Extract display fields
|
||||
let reply = json["reply"] as? String ?? ""
|
||||
let applied = json["applied"] as? Bool ?? false
|
||||
|
||||
// Parse drafts
|
||||
var drafts: [FitnessDraft] = []
|
||||
if let draft = json["draft"] as? [String: Any], let d = FitnessDraft(from: draft) {
|
||||
drafts.append(d)
|
||||
}
|
||||
if let draftsArray = json["drafts"] as? [[String: Any]] {
|
||||
for dict in draftsArray {
|
||||
if let d = FitnessDraft(from: dict) {
|
||||
drafts.append(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse sources
|
||||
var sources: [SourceLink] = []
|
||||
if let sourcesArray = json["sources"] as? [[String: Any]] {
|
||||
for dict in sourcesArray {
|
||||
if let s = SourceLink(from: dict) {
|
||||
sources.append(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for error
|
||||
if let errStr = json["error"] as? String, !errStr.isEmpty {
|
||||
error = errStr
|
||||
}
|
||||
|
||||
if !reply.isEmpty || !drafts.isEmpty {
|
||||
messages.append(ChatMessage(
|
||||
role: "assistant",
|
||||
content: reply,
|
||||
drafts: drafts,
|
||||
sources: sources,
|
||||
applied: applied
|
||||
))
|
||||
}
|
||||
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func applyDraft() async {
|
||||
await send(action: "apply")
|
||||
}
|
||||
|
||||
func loadPhoto(_ item: PhotosPickerItem?) async {
|
||||
guard let item else { return }
|
||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
||||
// Compress as JPEG
|
||||
if let img = UIImage(data: data), let jpeg = img.jpegData(compressionQuality: 0.7) {
|
||||
photoData = jpeg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
80
ios/Platform/Platform/Features/Auth/LoginView.swift
Normal file
80
ios/Platform/Platform/Features/Auth/LoginView.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@Environment(AuthManager.self) private var authManager
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var isLoading = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "square.grid.2x2.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(Color.accent)
|
||||
Text("Platform")
|
||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text("Sign in to continue")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
|
||||
VStack(spacing: 16) {
|
||||
TextField("Username", text: $username)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(14)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
|
||||
SecureField("Password", text: $password)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(14)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.textContentType(.password)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
if let error = authManager.loginError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
Button {
|
||||
isLoading = true
|
||||
Task {
|
||||
await authManager.login(username: username, password: password)
|
||||
isLoading = false
|
||||
}
|
||||
} label: {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text("Sign In")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 32)
|
||||
.disabled(username.isEmpty || password.isEmpty || isLoading)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
.background(Color.canvas.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
56
ios/Platform/Platform/Features/Fitness/API/FitnessAPI.swift
Normal file
56
ios/Platform/Platform/Features/Fitness/API/FitnessAPI.swift
Normal file
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
struct FitnessAPI {
|
||||
private let api = APIClient.shared
|
||||
|
||||
func getEntries(date: String) async throws -> [FoodEntry] {
|
||||
try await api.get("/api/fitness/entries?date=\(date)")
|
||||
}
|
||||
|
||||
func createEntry(_ req: CreateEntryRequest) async throws -> FoodEntry {
|
||||
try await api.post("/api/fitness/entries", body: req)
|
||||
}
|
||||
|
||||
func updateEntry(id: Int, quantity: Double) async throws -> FoodEntry {
|
||||
struct Body: Encodable { let quantity: Double }
|
||||
return try await api.patch("/api/fitness/entries/\(id)", body: Body(quantity: quantity))
|
||||
}
|
||||
|
||||
func deleteEntry(id: Int) async throws {
|
||||
try await api.delete("/api/fitness/entries/\(id)")
|
||||
}
|
||||
|
||||
func getFoods(limit: Int = 100) async throws -> [FoodItem] {
|
||||
try await api.get("/api/fitness/foods?limit=\(limit)")
|
||||
}
|
||||
|
||||
func searchFoods(query: String, limit: Int = 20) async throws -> [FoodItem] {
|
||||
let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
|
||||
return try await api.get("/api/fitness/foods/search?q=\(encoded)&limit=\(limit)")
|
||||
}
|
||||
|
||||
func getRecentFoods(limit: Int = 8) async throws -> [FoodItem] {
|
||||
try await api.get("/api/fitness/foods/recent?limit=\(limit)")
|
||||
}
|
||||
|
||||
func getFood(id: Int) async throws -> FoodItem {
|
||||
try await api.get("/api/fitness/foods/\(id)")
|
||||
}
|
||||
|
||||
func getGoals(date: String) async throws -> DailyGoal {
|
||||
try await api.get("/api/fitness/goals/for-date?date=\(date)")
|
||||
}
|
||||
|
||||
func updateGoals(_ req: UpdateGoalsRequest) async throws -> DailyGoal {
|
||||
try await api.put("/api/fitness/goals", body: req)
|
||||
}
|
||||
|
||||
func getTemplates() async throws -> [MealTemplate] {
|
||||
try await api.get("/api/fitness/templates")
|
||||
}
|
||||
|
||||
func logTemplate(id: Int, date: String) async throws {
|
||||
struct Empty: Decodable {}
|
||||
let _: Empty = try await api.post("/api/fitness/templates/\(id)/log?date=\(date)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Meal Type
|
||||
|
||||
enum MealType: String, Codable, CaseIterable, Identifiable {
|
||||
case breakfast, lunch, dinner, snack
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
rawValue.capitalized
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .breakfast: return "sunrise.fill"
|
||||
case .lunch: return "sun.max.fill"
|
||||
case .dinner: return "moon.fill"
|
||||
case .snack: return "leaf.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .breakfast: return .breakfastColor
|
||||
case .lunch: return .lunchColor
|
||||
case .dinner: return .dinnerColor
|
||||
case .snack: return .snackColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Food Entry
|
||||
|
||||
struct FoodEntry: Identifiable, Codable {
|
||||
let id: Int
|
||||
let userId: Int?
|
||||
let foodId: Int?
|
||||
let mealType: MealType
|
||||
let quantity: Double
|
||||
let entryDate: String
|
||||
let foodName: String
|
||||
let calories: Double
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let sugar: Double?
|
||||
let fiber: Double?
|
||||
let servingSize: String?
|
||||
let imageFilename: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case userId = "user_id"
|
||||
case foodId = "food_id"
|
||||
case mealType = "meal_type"
|
||||
case quantity
|
||||
case entryDate = "entry_date"
|
||||
case foodName = "food_name"
|
||||
case snapshotFoodName = "snapshot_food_name"
|
||||
case calories, protein, carbs, fat, sugar, fiber
|
||||
case servingSize = "serving_size"
|
||||
case imageFilename = "image_filename"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try Self.decodeIntFlex(c, .id)
|
||||
userId = try? Self.decodeIntFlex(c, .userId)
|
||||
foodId = try? Self.decodeIntFlex(c, .foodId)
|
||||
mealType = try c.decode(MealType.self, forKey: .mealType)
|
||||
quantity = try Self.decodeDoubleFlex(c, .quantity)
|
||||
entryDate = try c.decode(String.self, forKey: .entryDate)
|
||||
// Handle food_name or snapshot_food_name
|
||||
if let name = try? c.decode(String.self, forKey: .foodName) {
|
||||
foodName = name
|
||||
} else if let name = try? c.decode(String.self, forKey: .snapshotFoodName) {
|
||||
foodName = name
|
||||
} else {
|
||||
foodName = "Unknown"
|
||||
}
|
||||
calories = try Self.decodeDoubleFlex(c, .calories)
|
||||
protein = try Self.decodeDoubleFlex(c, .protein)
|
||||
carbs = try Self.decodeDoubleFlex(c, .carbs)
|
||||
fat = try Self.decodeDoubleFlex(c, .fat)
|
||||
sugar = try? Self.decodeDoubleFlex(c, .sugar)
|
||||
fiber = try? Self.decodeDoubleFlex(c, .fiber)
|
||||
servingSize = try? c.decode(String.self, forKey: .servingSize)
|
||||
imageFilename = try? c.decode(String.self, forKey: .imageFilename)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(id, forKey: .id)
|
||||
try c.encodeIfPresent(userId, forKey: .userId)
|
||||
try c.encodeIfPresent(foodId, forKey: .foodId)
|
||||
try c.encode(mealType, forKey: .mealType)
|
||||
try c.encode(quantity, forKey: .quantity)
|
||||
try c.encode(entryDate, forKey: .entryDate)
|
||||
try c.encode(foodName, forKey: .foodName)
|
||||
try c.encode(calories, forKey: .calories)
|
||||
try c.encode(protein, forKey: .protein)
|
||||
try c.encode(carbs, forKey: .carbs)
|
||||
try c.encode(fat, forKey: .fat)
|
||||
try c.encodeIfPresent(sugar, forKey: .sugar)
|
||||
try c.encodeIfPresent(fiber, forKey: .fiber)
|
||||
try c.encodeIfPresent(servingSize, forKey: .servingSize)
|
||||
try c.encodeIfPresent(imageFilename, forKey: .imageFilename)
|
||||
}
|
||||
|
||||
// Flexible Int decoding
|
||||
private static func decodeIntFlex(_ c: KeyedDecodingContainer<CodingKeys>, _ key: CodingKeys) throws -> Int {
|
||||
if let v = try? c.decode(Int.self, forKey: key) { return v }
|
||||
if let v = try? c.decode(Double.self, forKey: key) { return Int(v) }
|
||||
if let v = try? c.decode(String.self, forKey: key), let i = Int(v) { return i }
|
||||
throw DecodingError.typeMismatch(Int.self, .init(codingPath: [key], debugDescription: "Expected numeric"))
|
||||
}
|
||||
|
||||
// Flexible Double decoding
|
||||
private static func decodeDoubleFlex(_ c: KeyedDecodingContainer<CodingKeys>, _ key: CodingKeys) throws -> Double {
|
||||
if let v = try? c.decode(Double.self, forKey: key) { return v }
|
||||
if let v = try? c.decode(Int.self, forKey: key) { return Double(v) }
|
||||
if let v = try? c.decode(String.self, forKey: key), let d = Double(v) { return d }
|
||||
throw DecodingError.typeMismatch(Double.self, .init(codingPath: [key], debugDescription: "Expected numeric"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Food Item
|
||||
|
||||
struct FoodItem: Identifiable, Codable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let calories: Double
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let sugar: Double?
|
||||
let fiber: Double?
|
||||
let servingSize: String?
|
||||
let imageFilename: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, calories, protein, carbs, fat, sugar, fiber
|
||||
case servingSize = "serving_size"
|
||||
case imageFilename = "image_filename"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
if let v = try? c.decode(Int.self, forKey: .id) { id = v }
|
||||
else if let v = try? c.decode(Double.self, forKey: .id) { id = Int(v) }
|
||||
else { id = 0 }
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
calories = (try? c.decode(Double.self, forKey: .calories)) ?? Double((try? c.decode(Int.self, forKey: .calories)) ?? 0)
|
||||
protein = (try? c.decode(Double.self, forKey: .protein)) ?? Double((try? c.decode(Int.self, forKey: .protein)) ?? 0)
|
||||
carbs = (try? c.decode(Double.self, forKey: .carbs)) ?? Double((try? c.decode(Int.self, forKey: .carbs)) ?? 0)
|
||||
fat = (try? c.decode(Double.self, forKey: .fat)) ?? Double((try? c.decode(Int.self, forKey: .fat)) ?? 0)
|
||||
sugar = (try? c.decode(Double.self, forKey: .sugar)) ?? (try? c.decode(Int.self, forKey: .sugar)).map { Double($0) }
|
||||
fiber = (try? c.decode(Double.self, forKey: .fiber)) ?? (try? c.decode(Int.self, forKey: .fiber)).map { Double($0) }
|
||||
servingSize = try? c.decode(String.self, forKey: .servingSize)
|
||||
imageFilename = try? c.decode(String.self, forKey: .imageFilename)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Daily Goal
|
||||
|
||||
struct DailyGoal: Codable {
|
||||
let calories: Double
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let sugar: Double
|
||||
let fiber: Double
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case calories, protein, carbs, fat, sugar, fiber
|
||||
}
|
||||
|
||||
init(calories: Double = 2000, protein: Double = 150, carbs: Double = 250, fat: Double = 65, sugar: Double = 50, fiber: Double = 30) {
|
||||
self.calories = calories
|
||||
self.protein = protein
|
||||
self.carbs = carbs
|
||||
self.fat = fat
|
||||
self.sugar = sugar
|
||||
self.fiber = fiber
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
calories = (try? c.decode(Double.self, forKey: .calories)) ?? Double((try? c.decode(Int.self, forKey: .calories)) ?? 2000)
|
||||
protein = (try? c.decode(Double.self, forKey: .protein)) ?? Double((try? c.decode(Int.self, forKey: .protein)) ?? 150)
|
||||
carbs = (try? c.decode(Double.self, forKey: .carbs)) ?? Double((try? c.decode(Int.self, forKey: .carbs)) ?? 250)
|
||||
fat = (try? c.decode(Double.self, forKey: .fat)) ?? Double((try? c.decode(Int.self, forKey: .fat)) ?? 65)
|
||||
sugar = (try? c.decode(Double.self, forKey: .sugar)) ?? Double((try? c.decode(Int.self, forKey: .sugar)) ?? 50)
|
||||
fiber = (try? c.decode(Double.self, forKey: .fiber)) ?? Double((try? c.decode(Int.self, forKey: .fiber)) ?? 30)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Meal Template
|
||||
|
||||
struct MealTemplate: Identifiable, Codable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let mealType: MealType
|
||||
let totalCalories: Double?
|
||||
let totalProtein: Double?
|
||||
let totalCarbs: Double?
|
||||
let totalFat: Double?
|
||||
let itemCount: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name
|
||||
case mealType = "meal_type"
|
||||
case totalCalories = "total_calories"
|
||||
case totalProtein = "total_protein"
|
||||
case totalCarbs = "total_carbs"
|
||||
case totalFat = "total_fat"
|
||||
case itemCount = "item_count"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
if let v = try? c.decode(Int.self, forKey: .id) { id = v }
|
||||
else if let v = try? c.decode(Double.self, forKey: .id) { id = Int(v) }
|
||||
else { id = 0 }
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
mealType = try c.decode(MealType.self, forKey: .mealType)
|
||||
totalCalories = (try? c.decode(Double.self, forKey: .totalCalories)) ?? (try? c.decode(Int.self, forKey: .totalCalories)).map { Double($0) }
|
||||
totalProtein = (try? c.decode(Double.self, forKey: .totalProtein)) ?? (try? c.decode(Int.self, forKey: .totalProtein)).map { Double($0) }
|
||||
totalCarbs = (try? c.decode(Double.self, forKey: .totalCarbs)) ?? (try? c.decode(Int.self, forKey: .totalCarbs)).map { Double($0) }
|
||||
totalFat = (try? c.decode(Double.self, forKey: .totalFat)) ?? (try? c.decode(Int.self, forKey: .totalFat)).map { Double($0) }
|
||||
itemCount = (try? c.decode(Int.self, forKey: .itemCount)) ?? (try? c.decode(Double.self, forKey: .itemCount)).map { Int($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Requests
|
||||
|
||||
struct CreateEntryRequest: Encodable {
|
||||
let foodId: Int?
|
||||
let foodName: String
|
||||
let mealType: String
|
||||
let quantity: Double
|
||||
let entryDate: String
|
||||
let calories: Double
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let sugar: Double?
|
||||
let fiber: Double?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case foodId = "food_id"
|
||||
case foodName = "food_name"
|
||||
case mealType = "meal_type"
|
||||
case quantity
|
||||
case entryDate = "entry_date"
|
||||
case calories, protein, carbs, fat, sugar, fiber
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdateGoalsRequest: Encodable {
|
||||
let calories: Double
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let sugar: Double
|
||||
let fiber: Double
|
||||
}
|
||||
|
||||
// MARK: - Fitness Draft (AI Chat)
|
||||
|
||||
struct FitnessDraft {
|
||||
let foodName: String
|
||||
let mealType: String
|
||||
let calories: Double
|
||||
let protein: Double
|
||||
let carbs: Double
|
||||
let fat: Double
|
||||
let sugar: Double?
|
||||
let fiber: Double?
|
||||
let quantity: Double
|
||||
|
||||
init?(from dict: [String: Any]) {
|
||||
guard let name = dict["food_name"] as? String else { return nil }
|
||||
foodName = name
|
||||
mealType = (dict["meal_type"] as? String) ?? "snack"
|
||||
calories = Self.flexDouble(dict["calories"])
|
||||
protein = Self.flexDouble(dict["protein"])
|
||||
carbs = Self.flexDouble(dict["carbs"])
|
||||
fat = Self.flexDouble(dict["fat"])
|
||||
sugar = dict["sugar"].flatMap { Self.flexDoubleOpt($0) }
|
||||
fiber = dict["fiber"].flatMap { Self.flexDoubleOpt($0) }
|
||||
quantity = Self.flexDouble(dict["quantity"], default: 1)
|
||||
}
|
||||
|
||||
private static func flexDouble(_ val: Any?, default def: Double = 0) -> Double {
|
||||
if let v = val as? Double { return v }
|
||||
if let v = val as? Int { return Double(v) }
|
||||
if let v = val as? NSNumber { return v.doubleValue }
|
||||
return def
|
||||
}
|
||||
|
||||
private static func flexDoubleOpt(_ val: Any) -> Double? {
|
||||
if let v = val as? Double { return v }
|
||||
if let v = val as? Int { return Double(v) }
|
||||
if let v = val as? NSNumber { return v.doubleValue }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Source Link
|
||||
|
||||
struct SourceLink: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let type: String
|
||||
let href: String
|
||||
|
||||
init?(from dict: [String: Any]) {
|
||||
guard let id = dict["id"] as? String,
|
||||
let title = dict["title"] as? String else { return nil }
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.type = (dict["type"] as? String) ?? ""
|
||||
self.href = (dict["href"] as? String) ?? ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class FitnessRepository {
|
||||
static let shared = FitnessRepository()
|
||||
|
||||
var entries: [FoodEntry] = []
|
||||
var goals: DailyGoal = DailyGoal()
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
|
||||
private let api = FitnessAPI()
|
||||
|
||||
func loadDay(date: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
async let e = api.getEntries(date: date)
|
||||
async let g = api.getGoals(date: date)
|
||||
entries = try await e
|
||||
goals = try await g
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func deleteEntry(id: Int) async {
|
||||
do {
|
||||
try await api.deleteEntry(id: id)
|
||||
entries.removeAll { $0.id == id }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func addEntry(_ req: CreateEntryRequest) async {
|
||||
do {
|
||||
let entry = try await api.createEntry(req)
|
||||
entries.append(entry)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func updateEntry(id: Int, quantity: Double) async -> FoodEntry? {
|
||||
do {
|
||||
let updated = try await api.updateEntry(id: id, quantity: quantity)
|
||||
if let idx = entries.firstIndex(where: { $0.id == id }) {
|
||||
entries[idx] = updated
|
||||
}
|
||||
return updated
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Computed helpers
|
||||
var totalCalories: Double { entries.reduce(0) { $0 + $1.calories * $1.quantity } }
|
||||
var totalProtein: Double { entries.reduce(0) { $0 + $1.protein * $1.quantity } }
|
||||
var totalCarbs: Double { entries.reduce(0) { $0 + $1.carbs * $1.quantity } }
|
||||
var totalFat: Double { entries.reduce(0) { $0 + $1.fat * $1.quantity } }
|
||||
|
||||
func entriesForMeal(_ meal: MealType) -> [FoodEntry] {
|
||||
entries.filter { $0.mealType == meal }
|
||||
}
|
||||
|
||||
func mealCalories(_ meal: MealType) -> Double {
|
||||
entriesForMeal(meal).reduce(0) { $0 + $1.calories * $1.quantity }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class FoodSearchViewModel {
|
||||
var query = ""
|
||||
var results: [FoodItem] = []
|
||||
var recentFoods: [FoodItem] = []
|
||||
var allFoods: [FoodItem] = []
|
||||
var isSearching = false
|
||||
var isLoadingInitial = false
|
||||
|
||||
private let api = FitnessAPI()
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
func loadInitial() async {
|
||||
isLoadingInitial = true
|
||||
do {
|
||||
async let r = api.getRecentFoods()
|
||||
async let a = api.getFoods()
|
||||
recentFoods = try await r
|
||||
allFoods = try await a
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
isLoadingInitial = false
|
||||
}
|
||||
|
||||
func search() {
|
||||
searchTask?.cancel()
|
||||
let q = query.trimmingCharacters(in: .whitespaces)
|
||||
guard q.count >= 2 else {
|
||||
results = []
|
||||
isSearching = false
|
||||
return
|
||||
}
|
||||
isSearching = true
|
||||
searchTask = Task {
|
||||
do {
|
||||
let items = try await api.searchFoods(query: q)
|
||||
if !Task.isCancelled {
|
||||
results = items
|
||||
isSearching = false
|
||||
}
|
||||
} catch {
|
||||
if !Task.isCancelled {
|
||||
isSearching = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class GoalsViewModel {
|
||||
var calories: String = ""
|
||||
var protein: String = ""
|
||||
var carbs: String = ""
|
||||
var fat: String = ""
|
||||
var sugar: String = ""
|
||||
var fiber: String = ""
|
||||
var isLoading = false
|
||||
var isSaving = false
|
||||
var error: String?
|
||||
var saved = false
|
||||
|
||||
private let api = FitnessAPI()
|
||||
|
||||
func load(date: String) async {
|
||||
isLoading = true
|
||||
do {
|
||||
let goals = try await api.getGoals(date: date)
|
||||
calories = String(Int(goals.calories))
|
||||
protein = String(Int(goals.protein))
|
||||
carbs = String(Int(goals.carbs))
|
||||
fat = String(Int(goals.fat))
|
||||
sugar = String(Int(goals.sugar))
|
||||
fiber = String(Int(goals.fiber))
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func save() async {
|
||||
isSaving = true
|
||||
error = nil
|
||||
saved = false
|
||||
let req = UpdateGoalsRequest(
|
||||
calories: Double(calories) ?? 2000,
|
||||
protein: Double(protein) ?? 150,
|
||||
carbs: Double(carbs) ?? 250,
|
||||
fat: Double(fat) ?? 65,
|
||||
sugar: Double(sugar) ?? 50,
|
||||
fiber: Double(fiber) ?? 30
|
||||
)
|
||||
do {
|
||||
_ = try await api.updateGoals(req)
|
||||
saved = true
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class TemplatesViewModel {
|
||||
var templates: [MealTemplate] = []
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var logSuccess: String?
|
||||
|
||||
private let api = FitnessAPI()
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
templates = try await api.getTemplates()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func logTemplate(_ template: MealTemplate, date: String) async {
|
||||
do {
|
||||
try await api.logTemplate(id: template.id, date: date)
|
||||
logSuccess = "Logged \(template.name)"
|
||||
// Refresh the repository
|
||||
await FitnessRepository.shared.loadDay(date: date)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
var groupedByMeal: [MealType: [MealTemplate]] {
|
||||
Dictionary(grouping: templates, by: { $0.mealType })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class TodayViewModel {
|
||||
var selectedDate: Date = Date()
|
||||
let repository = FitnessRepository.shared
|
||||
|
||||
var dateString: String {
|
||||
selectedDate.apiDateString
|
||||
}
|
||||
|
||||
var displayDate: String {
|
||||
if selectedDate.isToday {
|
||||
return "Today"
|
||||
}
|
||||
return selectedDate.displayString
|
||||
}
|
||||
|
||||
func load() async {
|
||||
await repository.loadDay(date: dateString)
|
||||
}
|
||||
|
||||
func previousDay() {
|
||||
selectedDate = Calendar.current.date(byAdding: .day, value: -1, to: selectedDate) ?? selectedDate
|
||||
Task { await load() }
|
||||
}
|
||||
|
||||
func nextDay() {
|
||||
selectedDate = Calendar.current.date(byAdding: .day, value: 1, to: selectedDate) ?? selectedDate
|
||||
Task { await load() }
|
||||
}
|
||||
|
||||
func deleteEntry(_ entry: FoodEntry) async {
|
||||
await repository.deleteEntry(id: entry.id)
|
||||
}
|
||||
}
|
||||
171
ios/Platform/Platform/Features/Fitness/Views/AddFoodSheet.swift
Normal file
171
ios/Platform/Platform/Features/Fitness/Views/AddFoodSheet.swift
Normal file
@@ -0,0 +1,171 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddFoodSheet: View {
|
||||
let food: FoodItem
|
||||
let mealType: MealType
|
||||
let dateString: String
|
||||
let onAdded: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var quantity: Double = 1.0
|
||||
@State private var selectedMeal: MealType
|
||||
@State private var isAdding = false
|
||||
|
||||
init(food: FoodItem, mealType: MealType, dateString: String, onAdded: @escaping () -> Void) {
|
||||
self.food = food
|
||||
self.mealType = mealType
|
||||
self.dateString = dateString
|
||||
self.onAdded = onAdded
|
||||
_selectedMeal = State(initialValue: mealType)
|
||||
}
|
||||
|
||||
private var scaledCalories: Double { food.calories * quantity }
|
||||
private var scaledProtein: Double { food.protein * quantity }
|
||||
private var scaledCarbs: Double { food.carbs * quantity }
|
||||
private var scaledFat: Double { food.fat * quantity }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Food header
|
||||
VStack(spacing: 8) {
|
||||
if let img = food.imageFilename {
|
||||
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Color.surfaceSecondary
|
||||
}
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
Text(food.name)
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
|
||||
if let serving = food.servingSize {
|
||||
Text(serving)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Quantity
|
||||
VStack(spacing: 8) {
|
||||
Text("Quantity")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
HStack(spacing: 16) {
|
||||
Button { if quantity > 0.5 { quantity -= 0.5 } } label: {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accent)
|
||||
}
|
||||
Text(String(format: "%.1f", quantity))
|
||||
.font(.title2.bold())
|
||||
.frame(minWidth: 60)
|
||||
Button { quantity += 0.5 } label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Meal picker
|
||||
VStack(spacing: 8) {
|
||||
Text("Meal")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
HStack(spacing: 8) {
|
||||
ForEach(MealType.allCases) { meal in
|
||||
Button {
|
||||
selectedMeal = meal
|
||||
} label: {
|
||||
Text(meal.displayName)
|
||||
.font(.caption.bold())
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(selectedMeal == meal ? meal.color.opacity(0.2) : Color.surfaceSecondary)
|
||||
.foregroundStyle(selectedMeal == meal ? meal.color : Color.textSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nutrition preview
|
||||
VStack(spacing: 8) {
|
||||
nutritionRow("Calories", "\(Int(scaledCalories))")
|
||||
nutritionRow("Protein", "\(Int(scaledProtein))g")
|
||||
nutritionRow("Carbs", "\(Int(scaledCarbs))g")
|
||||
nutritionRow("Fat", "\(Int(scaledFat))g")
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
// Add button
|
||||
Button {
|
||||
isAdding = true
|
||||
Task {
|
||||
let req = CreateEntryRequest(
|
||||
foodId: food.id,
|
||||
foodName: food.name,
|
||||
mealType: selectedMeal.rawValue,
|
||||
quantity: quantity,
|
||||
entryDate: dateString,
|
||||
calories: food.calories,
|
||||
protein: food.protein,
|
||||
carbs: food.carbs,
|
||||
fat: food.fat,
|
||||
sugar: food.sugar,
|
||||
fiber: food.fiber
|
||||
)
|
||||
await FitnessRepository.shared.addEntry(req)
|
||||
isAdding = false
|
||||
dismiss()
|
||||
onAdded()
|
||||
}
|
||||
} label: {
|
||||
Group {
|
||||
if isAdding {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Add")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(Color.canvas)
|
||||
.navigationTitle("Add Food")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func nutritionRow(_ label: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EntryDetailView: View {
|
||||
let entry: FoodEntry
|
||||
let dateString: String
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var quantity: Double
|
||||
@State private var isDeleting = false
|
||||
@State private var isSaving = false
|
||||
|
||||
init(entry: FoodEntry, dateString: String) {
|
||||
self.entry = entry
|
||||
self.dateString = dateString
|
||||
_quantity = State(initialValue: entry.quantity)
|
||||
}
|
||||
|
||||
private var scaledCalories: Double { entry.calories * quantity }
|
||||
private var scaledProtein: Double { entry.protein * quantity }
|
||||
private var scaledCarbs: Double { entry.carbs * quantity }
|
||||
private var scaledFat: Double { entry.fat * quantity }
|
||||
private var scaledSugar: Double? { entry.sugar.map { $0 * quantity } }
|
||||
private var scaledFiber: Double? { entry.fiber.map { $0 * quantity } }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Food name
|
||||
Text(entry.foodName)
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
|
||||
// Meal badge
|
||||
HStack {
|
||||
Image(systemName: entry.mealType.icon)
|
||||
Text(entry.mealType.displayName)
|
||||
}
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(entry.mealType.color)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(entry.mealType.color.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
// Quantity editor
|
||||
VStack(spacing: 8) {
|
||||
Text("Quantity")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
HStack(spacing: 16) {
|
||||
Button { if quantity > 0.5 { quantity -= 0.5 } } label: {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accent)
|
||||
}
|
||||
Text(String(format: "%.1f", quantity))
|
||||
.font(.title2.bold())
|
||||
.frame(minWidth: 60)
|
||||
Button { quantity += 0.5 } label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if quantity != entry.quantity {
|
||||
Button {
|
||||
isSaving = true
|
||||
Task {
|
||||
_ = await FitnessRepository.shared.updateEntry(id: entry.id, quantity: quantity)
|
||||
isSaving = false
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
Group {
|
||||
if isSaving {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Update Quantity")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// Nutrition grid
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
||||
nutritionCell("Calories", "\(Int(scaledCalories))", "kcal")
|
||||
nutritionCell("Protein", "\(Int(scaledProtein))", "g")
|
||||
nutritionCell("Carbs", "\(Int(scaledCarbs))", "g")
|
||||
nutritionCell("Fat", "\(Int(scaledFat))", "g")
|
||||
if let sugar = scaledSugar {
|
||||
nutritionCell("Sugar", "\(Int(sugar))", "g")
|
||||
}
|
||||
if let fiber = scaledFiber {
|
||||
nutritionCell("Fiber", "\(Int(fiber))", "g")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
|
||||
// Delete
|
||||
Button(role: .destructive) {
|
||||
isDeleting = true
|
||||
Task {
|
||||
await FitnessRepository.shared.deleteEntry(id: entry.id)
|
||||
isDeleting = false
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
if isDeleting {
|
||||
ProgressView().tint(.red)
|
||||
} else {
|
||||
Image(systemName: "trash")
|
||||
Text("Delete Entry")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(Color.canvas)
|
||||
.navigationTitle("Entry Detail")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func nutritionCell(_ label: String, _ value: String, _ unit: String) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(value)
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text("\(label) (\(unit))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(12)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import SwiftUI
|
||||
|
||||
enum FitnessTab: String, CaseIterable {
|
||||
case today = "Today"
|
||||
case templates = "Templates"
|
||||
case goals = "Goals"
|
||||
case foods = "Foods"
|
||||
}
|
||||
|
||||
struct FitnessTabView: View {
|
||||
@State private var selectedTab: FitnessTab = .today
|
||||
@State private var showAssistant = false
|
||||
@State private var todayVM = TodayViewModel()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
VStack(spacing: 0) {
|
||||
// Tab bar
|
||||
HStack(spacing: 24) {
|
||||
ForEach(FitnessTab.allCases, id: \.self) { tab in
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
selectedTab = tab
|
||||
}
|
||||
} label: {
|
||||
Text(tab.rawValue)
|
||||
.font(.subheadline)
|
||||
.fontWeight(selectedTab == tab ? .bold : .medium)
|
||||
.foregroundStyle(selectedTab == tab ? Color.accent : Color.textSecondary)
|
||||
.padding(.bottom, 8)
|
||||
.overlay(alignment: .bottom) {
|
||||
if selectedTab == tab {
|
||||
Rectangle()
|
||||
.fill(Color.accent)
|
||||
.frame(height: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 60)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// Content
|
||||
TabView(selection: $selectedTab) {
|
||||
TodayView(viewModel: todayVM)
|
||||
.tag(FitnessTab.today)
|
||||
TemplatesView(dateString: todayVM.dateString)
|
||||
.tag(FitnessTab.templates)
|
||||
GoalsView(dateString: todayVM.dateString)
|
||||
.tag(FitnessTab.goals)
|
||||
FoodLibraryView(dateString: todayVM.dateString)
|
||||
.tag(FitnessTab.foods)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
}
|
||||
|
||||
// Floating + button
|
||||
Button {
|
||||
showAssistant = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Color.accent)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: Color.accent.opacity(0.3), radius: 8, y: 4)
|
||||
}
|
||||
.padding(.trailing, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.background(Color.canvas.ignoresSafeArea())
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.sheet(isPresented: $showAssistant) {
|
||||
AssistantChatView(entryDate: todayVM.dateString) {
|
||||
Task { await todayVM.load() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FoodLibraryView: View {
|
||||
let dateString: String
|
||||
@State private var vm = FoodSearchViewModel()
|
||||
@State private var selectedFood: FoodItem?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Search bar
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
TextField("Search foods...", text: $vm.query)
|
||||
.textFieldStyle(.plain)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: vm.query) { _, _ in
|
||||
vm.search()
|
||||
}
|
||||
if !vm.query.isEmpty {
|
||||
Button { vm.query = ""; vm.results = [] } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
if vm.isLoadingInitial {
|
||||
LoadingView()
|
||||
} else {
|
||||
let foods = vm.query.count >= 2 ? vm.results : vm.allFoods
|
||||
if foods.isEmpty {
|
||||
EmptyStateView(icon: "fork.knife", title: "No foods", subtitle: "Your food library is empty")
|
||||
} else {
|
||||
List(foods) { food in
|
||||
Button {
|
||||
selectedFood = food
|
||||
} label: {
|
||||
HStack {
|
||||
if let img = food.imageFilename {
|
||||
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Color.surfaceSecondary
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(food.name)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
HStack(spacing: 8) {
|
||||
Text("\(Int(food.calories)) cal")
|
||||
Text("P:\(Int(food.protein))g")
|
||||
Text("C:\(Int(food.carbs))g")
|
||||
Text("F:\(Int(food.fat))g")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await vm.loadInitial()
|
||||
}
|
||||
.sheet(item: $selectedFood) { food in
|
||||
AddFoodSheet(food: food, mealType: .snack, dateString: dateString) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FoodSearchView: View {
|
||||
let mealType: MealType
|
||||
let dateString: String
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var vm = FoodSearchViewModel()
|
||||
@State private var selectedFood: FoodItem?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Search bar
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
TextField("Search foods...", text: $vm.query)
|
||||
.textFieldStyle(.plain)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: vm.query) { _, _ in
|
||||
vm.search()
|
||||
}
|
||||
if !vm.query.isEmpty {
|
||||
Button { vm.query = ""; vm.results = [] } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
if vm.isSearching || vm.isLoadingInitial {
|
||||
LoadingView()
|
||||
} else if !vm.query.isEmpty && vm.query.count >= 2 {
|
||||
// Search results
|
||||
List {
|
||||
Section {
|
||||
ForEach(vm.results) { food in
|
||||
foodRow(food)
|
||||
}
|
||||
} header: {
|
||||
Text("\(vm.results.count) results")
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
} else {
|
||||
// Default: recent + all
|
||||
List {
|
||||
if !vm.recentFoods.isEmpty {
|
||||
Section("Recent") {
|
||||
ForEach(vm.recentFoods) { food in
|
||||
foodRow(food)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !vm.allFoods.isEmpty {
|
||||
Section("All Foods") {
|
||||
ForEach(vm.allFoods) { food in
|
||||
foodRow(food)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.background(Color.canvas)
|
||||
.navigationTitle("Add Food")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await vm.loadInitial()
|
||||
}
|
||||
.sheet(item: $selectedFood) { food in
|
||||
AddFoodSheet(food: food, mealType: mealType, dateString: dateString) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func foodRow(_ food: FoodItem) -> some View {
|
||||
Button {
|
||||
selectedFood = food
|
||||
} label: {
|
||||
HStack {
|
||||
if let img = food.imageFilename {
|
||||
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Color.surfaceSecondary
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(food.name)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text("\(Int(food.calories)) cal")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "plus.circle")
|
||||
.foregroundStyle(Color.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
ios/Platform/Platform/Features/Fitness/Views/GoalsView.swift
Normal file
85
ios/Platform/Platform/Features/Fitness/Views/GoalsView.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GoalsView: View {
|
||||
let dateString: String
|
||||
@State private var vm = GoalsViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
if vm.isLoading {
|
||||
LoadingView()
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
goalField("Calories", value: $vm.calories, unit: "kcal")
|
||||
goalField("Protein", value: $vm.protein, unit: "g")
|
||||
goalField("Carbs", value: $vm.carbs, unit: "g")
|
||||
goalField("Fat", value: $vm.fat, unit: "g")
|
||||
goalField("Sugar", value: $vm.sugar, unit: "g")
|
||||
goalField("Fiber", value: $vm.fiber, unit: "g")
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Button {
|
||||
Task { await vm.save() }
|
||||
} label: {
|
||||
Group {
|
||||
if vm.isSaving {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Save Goals")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.disabled(vm.isSaving)
|
||||
|
||||
if vm.saved {
|
||||
Text("Goals saved!")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.emerald)
|
||||
}
|
||||
|
||||
if let err = vm.error {
|
||||
ErrorBanner(message: err)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 80)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.task {
|
||||
await vm.load(date: dateString)
|
||||
}
|
||||
}
|
||||
|
||||
private func goalField(_ label: String, value: Binding<String>, unit: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
.frame(width: 80, alignment: .leading)
|
||||
TextField("0", text: value)
|
||||
.keyboardType(.numberPad)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.subheadline.bold())
|
||||
.padding(10)
|
||||
.background(Color.surfaceSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
Text(unit)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
.frame(width: 32)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MealSectionView: View {
|
||||
let meal: MealType
|
||||
let entries: [FoodEntry]
|
||||
let mealCalories: Double
|
||||
let onDelete: (FoodEntry) -> Void
|
||||
let dateString: String
|
||||
|
||||
@State private var isExpanded = true
|
||||
@State private var showFoodSearch = false
|
||||
@State private var selectedEntry: FoodEntry?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 0) {
|
||||
// Colored accent bar
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(meal.color)
|
||||
.frame(width: 4, height: 44)
|
||||
.padding(.trailing, 12)
|
||||
|
||||
Image(systemName: meal.icon)
|
||||
.font(.headline)
|
||||
.foregroundStyle(meal.color)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(meal.displayName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text("\(entries.count) item\(entries.count == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(Int(mealCalories)) cal")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(meal.color)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// Entries
|
||||
if isExpanded && !entries.isEmpty {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(entries) { entry in
|
||||
Button {
|
||||
selectedEntry = entry
|
||||
} label: {
|
||||
entryRow(entry)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
onDelete(entry)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 40)
|
||||
}
|
||||
|
||||
// Add button
|
||||
if isExpanded {
|
||||
Button {
|
||||
showFoodSearch = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundStyle(meal.color.opacity(0.6))
|
||||
Text("Add food")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.leading, 44)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(meal.color.opacity(0.03))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.sheet(isPresented: $showFoodSearch) {
|
||||
FoodSearchView(mealType: meal, dateString: dateString)
|
||||
}
|
||||
.sheet(item: $selectedEntry) { entry in
|
||||
EntryDetailView(entry: entry, dateString: dateString)
|
||||
}
|
||||
}
|
||||
|
||||
private func entryRow(_ entry: FoodEntry) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(entry.foodName)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
.lineLimit(1)
|
||||
if entry.quantity != 1 {
|
||||
Text("x\(String(format: "%.1f", entry.quantity))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text("\(Int(entry.calories * entry.quantity))")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
103
ios/Platform/Platform/Features/Fitness/Views/TemplatesView.swift
Normal file
103
ios/Platform/Platform/Features/Fitness/Views/TemplatesView.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TemplatesView: View {
|
||||
let dateString: String
|
||||
@State private var vm = TemplatesViewModel()
|
||||
@State private var templateToLog: MealTemplate?
|
||||
@State private var showConfirm = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
if vm.isLoading {
|
||||
LoadingView()
|
||||
} else if vm.templates.isEmpty {
|
||||
EmptyStateView(icon: "doc.on.doc", title: "No templates", subtitle: "Create meal templates from the web app")
|
||||
} else {
|
||||
ForEach(MealType.allCases) { meal in
|
||||
let templates = vm.groupedByMeal[meal] ?? []
|
||||
if !templates.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: meal.icon)
|
||||
.foregroundStyle(meal.color)
|
||||
Text(meal.displayName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
ForEach(templates) { template in
|
||||
templateCard(template)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let msg = vm.logSuccess {
|
||||
Text(msg)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.emerald)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
if let err = vm.error {
|
||||
ErrorBanner(message: err)
|
||||
}
|
||||
|
||||
Spacer(minLength: 80)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.task {
|
||||
await vm.load()
|
||||
}
|
||||
.alert("Log Meal", isPresented: $showConfirm, presenting: templateToLog) { template in
|
||||
Button("Log") {
|
||||
Task { await vm.logTemplate(template, date: dateString) }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { template in
|
||||
Text("Add \(template.name) to today's log?")
|
||||
}
|
||||
}
|
||||
|
||||
private func templateCard(_ template: MealTemplate) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(template.name)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
HStack(spacing: 8) {
|
||||
if let cal = template.totalCalories {
|
||||
Text("\(Int(cal)) cal")
|
||||
}
|
||||
if let items = template.itemCount {
|
||||
Text("\(items) items")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
templateToLog = template
|
||||
showConfirm = true
|
||||
} label: {
|
||||
Text("Log meal")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: .black.opacity(0.03), radius: 4, y: 1)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
96
ios/Platform/Platform/Features/Fitness/Views/TodayView.swift
Normal file
96
ios/Platform/Platform/Features/Fitness/Views/TodayView.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TodayView: View {
|
||||
@Bindable var viewModel: TodayViewModel
|
||||
@State private var showFoodSearch = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// Date selector
|
||||
HStack {
|
||||
Button { viewModel.previousDay() } label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.accent)
|
||||
}
|
||||
Spacer()
|
||||
Text(viewModel.displayDate)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Spacer()
|
||||
Button { viewModel.nextDay() } label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.accent)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 50)
|
||||
.onEnded { value in
|
||||
if value.translation.width > 0 {
|
||||
viewModel.previousDay()
|
||||
} else {
|
||||
viewModel.nextDay()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Macro summary card
|
||||
if !viewModel.repository.isLoading {
|
||||
macroSummaryCard
|
||||
}
|
||||
|
||||
// Error
|
||||
if let error = viewModel.repository.error {
|
||||
ErrorBanner(message: error) { await viewModel.load() }
|
||||
}
|
||||
|
||||
// Meal sections
|
||||
ForEach(MealType.allCases) { meal in
|
||||
MealSectionView(
|
||||
meal: meal,
|
||||
entries: viewModel.repository.entriesForMeal(meal),
|
||||
mealCalories: viewModel.repository.mealCalories(meal),
|
||||
onDelete: { entry in
|
||||
Task { await viewModel.deleteEntry(entry) }
|
||||
},
|
||||
dateString: viewModel.dateString
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(minLength: 80)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.load()
|
||||
}
|
||||
.task {
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
|
||||
private var macroSummaryCard: some View {
|
||||
let repo = viewModel.repository
|
||||
let goals = repo.goals
|
||||
|
||||
return VStack(spacing: 12) {
|
||||
HStack(spacing: 20) {
|
||||
LargeCalorieRing(consumed: repo.totalCalories, goal: goals.calories)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
MacroBar(label: "Protein", value: repo.totalProtein, goal: goals.protein, color: .blue)
|
||||
MacroBar(label: "Carbs", value: repo.totalCarbs, goal: goals.carbs, color: .orange)
|
||||
MacroBar(label: "Fat", value: repo.totalFat, goal: goals.fat, color: .purple)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
161
ios/Platform/Platform/Features/Home/HomeView.swift
Normal file
161
ios/Platform/Platform/Features/Home/HomeView.swift
Normal file
@@ -0,0 +1,161 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
struct HomeView: View {
|
||||
@Environment(AuthManager.self) private var authManager
|
||||
@State private var vm = HomeViewModel()
|
||||
@State private var showAssistant = false
|
||||
@State private var showProfileMenu = false
|
||||
@State private var selectedPhoto: PhotosPickerItem?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
// Background
|
||||
if let bg = vm.backgroundImage {
|
||||
Image(uiImage: bg)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.ignoresSafeArea()
|
||||
.overlay(Color.black.opacity(0.2).ignoresSafeArea())
|
||||
}
|
||||
else {
|
||||
Color.canvas.ignoresSafeArea()
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// Top bar
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Dashboard")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(vm.backgroundImage != nil ? .white : Color.textPrimary)
|
||||
if let name = authManager.user?.displayName ?? authManager.user?.username {
|
||||
Text("Welcome, \(name)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(vm.backgroundImage != nil ? .white.opacity(0.8) : Color.textSecondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Menu {
|
||||
PhotosPicker(selection: $selectedPhoto, matching: .images) {
|
||||
Label("Change Background", systemImage: "photo")
|
||||
}
|
||||
if vm.backgroundImage != nil {
|
||||
Button(role: .destructive) {
|
||||
vm.removeBackground()
|
||||
} label: {
|
||||
Label("Remove Background", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive) {
|
||||
authManager.logout()
|
||||
} label: {
|
||||
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(vm.backgroundImage != nil ? .white : Color.accent)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 60)
|
||||
|
||||
// Widget grid
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
|
||||
calorieWidget
|
||||
quickStatsWidget
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Spacer(minLength: 100)
|
||||
}
|
||||
}
|
||||
|
||||
// Floating + button
|
||||
Button {
|
||||
showAssistant = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Color.accent)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: Color.accent.opacity(0.3), radius: 8, y: 4)
|
||||
}
|
||||
.padding(.trailing, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.sheet(isPresented: $showAssistant) {
|
||||
AssistantChatView(entryDate: Date().apiDateString) {
|
||||
Task { await vm.loadData() }
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedPhoto) { _, newValue in
|
||||
guard let item = newValue else { return }
|
||||
Task {
|
||||
if let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) {
|
||||
vm.setBackground(image)
|
||||
}
|
||||
selectedPhoto = nil
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await vm.loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var calorieWidget: some View {
|
||||
let hasBg = vm.backgroundImage != nil
|
||||
return VStack(spacing: 8) {
|
||||
LargeCalorieRing(consumed: vm.caloriesConsumed, goal: vm.caloriesGoal)
|
||||
Text("Calories")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(hasBg ? .white : Color.textSecondary)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
if hasBg {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.ultraThinMaterial)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.surface)
|
||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var quickStatsWidget: some View {
|
||||
let hasBg = vm.backgroundImage != nil
|
||||
let repo = FitnessRepository.shared
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Macros")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(hasBg ? .white : Color.textSecondary)
|
||||
MacroBar(label: "Protein", value: repo.totalProtein, goal: repo.goals.protein, color: .blue, compact: true)
|
||||
MacroBar(label: "Carbs", value: repo.totalCarbs, goal: repo.goals.carbs, color: .orange, compact: true)
|
||||
MacroBar(label: "Fat", value: repo.totalFat, goal: repo.goals.fat, color: .purple, compact: true)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
if hasBg {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.ultraThinMaterial)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.surface)
|
||||
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
ios/Platform/Platform/Features/Home/HomeViewModel.swift
Normal file
54
ios/Platform/Platform/Features/Home/HomeViewModel.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
@Observable
|
||||
final class HomeViewModel {
|
||||
var backgroundImage: UIImage?
|
||||
var caloriesConsumed: Double = 0
|
||||
var caloriesGoal: Double = 2000
|
||||
var isLoading = false
|
||||
|
||||
private let bgKey = "homeBackgroundImage"
|
||||
private let repo = FitnessRepository.shared
|
||||
|
||||
init() {
|
||||
loadBackgroundFromDefaults()
|
||||
}
|
||||
|
||||
func loadData() async {
|
||||
isLoading = true
|
||||
let today = Date().apiDateString
|
||||
await repo.loadDay(date: today)
|
||||
caloriesConsumed = repo.totalCalories
|
||||
caloriesGoal = repo.goals.calories
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func setBackground(_ image: UIImage) {
|
||||
// Resize to max 1200px
|
||||
let maxDim: CGFloat = 1200
|
||||
let scale = min(maxDim / image.size.width, maxDim / image.size.height, 1.0)
|
||||
let newSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
|
||||
UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0)
|
||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||
let resized = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
|
||||
if let resized, let data = resized.jpegData(compressionQuality: 0.8) {
|
||||
UserDefaults.standard.set(data, forKey: bgKey)
|
||||
backgroundImage = resized
|
||||
}
|
||||
}
|
||||
|
||||
func removeBackground() {
|
||||
UserDefaults.standard.removeObject(forKey: bgKey)
|
||||
backgroundImage = nil
|
||||
}
|
||||
|
||||
private func loadBackgroundFromDefaults() {
|
||||
if let data = UserDefaults.standard.data(forKey: bgKey),
|
||||
let img = UIImage(data: data) {
|
||||
backgroundImage = img
|
||||
}
|
||||
}
|
||||
}
|
||||
10
ios/Platform/Platform/Info.plist
Normal file
10
ios/Platform/Platform/Info.plist
Normal file
@@ -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>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
13
ios/Platform/Platform/PlatformApp.swift
Normal file
13
ios/Platform/Platform/PlatformApp.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct PlatformApp: App {
|
||||
@State private var authManager = AuthManager()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(authManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
66
ios/Platform/Platform/Shared/Components/LoadingView.swift
Normal file
66
ios/Platform/Platform/Shared/Components/LoadingView.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoadingView: View {
|
||||
var message: String = "Loading..."
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.tint(.accent)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct ErrorBanner: View {
|
||||
let message: String
|
||||
var retry: (() async -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Spacer()
|
||||
if let retry = retry {
|
||||
Button("Retry") {
|
||||
Task { await retry() }
|
||||
}
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.accent)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.orange.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyStateView: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(40)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
44
ios/Platform/Platform/Shared/Components/MacroBar.swift
Normal file
44
ios/Platform/Platform/Shared/Components/MacroBar.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MacroBar: View {
|
||||
let label: String
|
||||
let value: Double
|
||||
let goal: Double
|
||||
let color: Color
|
||||
var compact: Bool = false
|
||||
|
||||
private var progress: Double {
|
||||
guard goal > 0 else { return 0 }
|
||||
return min(max(value / goal, 0), 1)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: compact ? 2 : 4) {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(compact ? .caption2 : .caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
Spacer()
|
||||
Text("\(Int(value))/\(Int(goal))g")
|
||||
.font(compact ? .caption2 : .caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
}
|
||||
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: compact ? 2 : 3)
|
||||
.fill(color.opacity(0.15))
|
||||
.frame(height: compact ? 4 : 6)
|
||||
|
||||
RoundedRectangle(cornerRadius: compact ? 2 : 3)
|
||||
.fill(color)
|
||||
.frame(width: max(0, geo.size.width * progress), height: compact ? 4 : 6)
|
||||
.animation(.easeInOut(duration: 0.5), value: progress)
|
||||
}
|
||||
}
|
||||
.frame(height: compact ? 4 : 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
83
ios/Platform/Platform/Shared/Components/MacroRing.swift
Normal file
83
ios/Platform/Platform/Shared/Components/MacroRing.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MacroRing: View {
|
||||
let consumed: Double
|
||||
let goal: Double
|
||||
let color: Color
|
||||
var size: CGFloat = 80
|
||||
var lineWidth: CGFloat = 8
|
||||
var showLabel: Bool = true
|
||||
var labelFontSize: CGFloat = 14
|
||||
|
||||
private var progress: Double {
|
||||
guard goal > 0 else { return 0 }
|
||||
return min(max(consumed / goal, 0), 1)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(color.opacity(0.15), lineWidth: lineWidth)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.6), value: progress)
|
||||
|
||||
if showLabel {
|
||||
VStack(spacing: 0) {
|
||||
Text("\(Int(consumed))")
|
||||
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
if goal > 0 {
|
||||
Text("/ \(Int(goal))")
|
||||
.font(.system(size: labelFontSize * 0.65, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct LargeCalorieRing: View {
|
||||
let consumed: Double
|
||||
let goal: Double
|
||||
|
||||
private var remaining: Int {
|
||||
max(0, Int(goal) - Int(consumed))
|
||||
}
|
||||
|
||||
private var progress: Double {
|
||||
guard goal > 0 else { return 0 }
|
||||
return min(max(consumed / goal, 0), 1)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.emerald.opacity(0.15), lineWidth: 14)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(
|
||||
Color.emerald,
|
||||
style: StrokeStyle(lineWidth: 14, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.8), value: progress)
|
||||
|
||||
VStack(spacing: 2) {
|
||||
Text("\(Int(consumed))")
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
Text("\(remaining) left")
|
||||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(Color.textSecondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 120, height: 120)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
init(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int: UInt64 = 0
|
||||
Scanner(string: hex).scanHexInt64(&int)
|
||||
let a, r, g, b: UInt64
|
||||
switch hex.count {
|
||||
case 6:
|
||||
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||
case 8:
|
||||
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||
default:
|
||||
(a, r, g, b) = (255, 0, 0, 0)
|
||||
}
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double(r) / 255,
|
||||
green: Double(g) / 255,
|
||||
blue: Double(b) / 255,
|
||||
opacity: Double(a) / 255
|
||||
)
|
||||
}
|
||||
|
||||
// Core palette
|
||||
static let canvas = Color(hex: "F5EFE6")
|
||||
static let surface = Color(hex: "FFFFFF")
|
||||
static let surfaceSecondary = Color(hex: "FAF7F2")
|
||||
static let accent = Color(hex: "8B6914")
|
||||
static let accentLight = Color(hex: "D4A843")
|
||||
static let emerald = Color(hex: "059669")
|
||||
static let textPrimary = Color(hex: "1C1917")
|
||||
static let textSecondary = Color(hex: "78716C")
|
||||
static let textTertiary = Color(hex: "A8A29E")
|
||||
static let border = Color(hex: "E7E5E4")
|
||||
|
||||
// Meal colors
|
||||
static let breakfastColor = Color(hex: "F59E0B")
|
||||
static let lunchColor = Color(hex: "059669")
|
||||
static let dinnerColor = Color(hex: "8B5CF6")
|
||||
static let snackColor = Color(hex: "EC4899")
|
||||
|
||||
// Chat
|
||||
static let userBubble = Color(hex: "8B6914").opacity(0.15)
|
||||
static let assistantBubble = Color(hex: "F5F5F4")
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
var apiDateString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var displayString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE, MMM d"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var shortDisplayString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d, yyyy"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var isToday: Bool {
|
||||
Calendar.current.isDateInToday(self)
|
||||
}
|
||||
|
||||
static func from(apiString: String) -> Date? {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter.date(from: apiString)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user