// // DashboardWidget.swift // DashboardWidget // // Created by hillel on 12/06/2023. // import WidgetKit import SwiftUI import Intents 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 3: // RGB (12-bit) (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) case 6: // RGB (24-bit) (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) case 8: // ARGB (32-bit) (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) default: (a, r, g, b) = (1, 1, 1, 0) } self.init( .sRGB, red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255, opacity: Double(a) / 255 ) } } struct Provider: IntentTimelineProvider { func placeholder(in context: Context) -> SimpleEntry { SimpleEntry(date: Date(), configuration: ConfigurationIntent(), widgetData: WidgetData(url: "url", companyId: "", companies: [:], dateRanges: [:]), field: "Active Invoices", value: "$100.00") } func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) { let entry = SimpleEntry(date: Date(), configuration: configuration, widgetData: WidgetData(url: "url", companyId: "", companies: [:], dateRanges: [:]), field: "Active Invoices", value: "$100.00") completion(entry) } func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline) -> ()) { print("## getTimeline") Task { let sharedDefaults = UserDefaults.init(suiteName: "group.com.invoiceninja.app") var widgetData: WidgetData? = nil if sharedDefaults == nil { return } do { let shared = sharedDefaults!.string(forKey: "widget_data") if shared == nil { return } //print("## Shared: \(shared!)") let decoder = JSONDecoder() widgetData = try decoder.decode(WidgetData.self, from: shared!.data(using: .utf8)!) if (widgetData?.url == nil) { return } let url = (widgetData?.url ?? "") + "/charts/totals_v2"; let companyId = configuration.company?.identifier ?? "" let company = widgetData?.companies[companyId] var token = company?.token let (startDate, endDate) = getDateRange(dateRange: (configuration.dateRange?.identifier)!, firstMonthOfYear: company!.firstMonthOfYear) if (token == "" && !(widgetData?.companies.isEmpty)!) { print("## WARNING: using first token") let company = widgetData?.companies.values.first; token = company?.token ?? "" } print("## company.name: \(configuration.company?.displayString ?? "")") print("## company.id: \(configuration.company?.identifier ?? "")") print("## Date Range: \(String(describing: configuration.dateRange?.identifier)) => \(startDate) - \(endDate)") //print("## URL: \(url)") if (token == "") { return } guard let result = try? await ApiService.post(urlString: url, apiToken: token!, startDate: startDate, endDate: endDate) else { return } let currencyId = configuration.currency?.identifier ?? company?.currencyId let currency = company?.currencies[currencyId!] var value = 0.0 var label = "" let data = result[currencyId ?? "1"] if (data != nil) { if (configuration.field == Field.active_invoices) { if (data?.invoices?.invoicedAmount != nil) { value = Double(data?.invoices?.invoicedAmount ?? "")! } label = "Active Invoices" } else if (configuration.field == Field.outstanding_invoices) { if (data?.outstanding?.amount != nil) { value = Double(data?.outstanding?.amount ?? "")! } label = "Outstanding Invoices" } else if (configuration.field == Field.completed_payments) { if (data?.revenue?.paidToDate != nil) { value = Double(data?.revenue?.paidToDate ?? "")! } label = "Completed Payments" } } let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.currencyCode = currency?.code ?? "USD" let entry = SimpleEntry(date: Date(), configuration: configuration, widgetData: widgetData, field: label, value: formatter.string(from: NSNumber(value: value))!) // Next fetch happens 15 minutes later let nextUpdate = Calendar.current.date( byAdding: DateComponents(minute: 15), to: Date() )! let timeline = Timeline( entries: [entry], policy: .after(nextUpdate) ) completion(timeline) } catch { print(error) } } } func getDateRange(dateRange: String, firstMonthOfYear: Int = 1) -> (start: String, end: String) { let today = Date() let calendar = Calendar.current let firstDayOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: today))! let year = calendar.component(.year, from: today) - (firstMonthOfYear > calendar.component(.month, from: today) ? 1 : 0) let firstDayOfYear = calendar.date(from: DateComponents(year: year, month: firstMonthOfYear, day: 1))! let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" var start: Date = Date() var end: Date = Date() var dateComponents = calendar.dateComponents([.year, .month, .day], from: Date()) if (dateRange == "all_time") { start = calendar.date(byAdding: .year, value: -100, to: Date())! } else if (dateRange == "today") { start = calendar.startOfDay(for: Date()) } else if (dateRange == "yesterday") { start = calendar.date(byAdding: .day, value: -1, to: Date())! end = start } else if (dateRange == "last7_days" ){ start = calendar.date(byAdding: .day, value: -7, to: Date())! } else if (dateRange == "last30_days") { start = calendar.date(byAdding: .day, value: -30, to: Date())! } else if (dateRange == "last365_days") { start = calendar.date(byAdding: .day, value: -365, to: Date())! } else if (dateRange == "this_month") { start = calendar.date(from: calendar.dateComponents([.year, .month], from: Date()))! } else if (dateRange == "last_month") { let previousMonthDate = calendar.date(byAdding: .month, value: -1, to: Date())! start = calendar.date(from: calendar.dateComponents([.year, .month], from: previousMonthDate))! end = calendar.date(byAdding: .month, value: 1, to: start)!.addingTimeInterval(-1) } else if (dateRange == "this_quarter") { let monthOffset = (calendar.component(.month, from: today) - 1) % 3 * -1 start = calendar.date(byAdding: .month, value: monthOffset, to: firstDayOfMonth)! end = calendar.date(byAdding: .month, value: 3, to: start)!.addingTimeInterval(-1) } else if (dateRange == "last_quarter") { let monthOffset = (calendar.component(.month, from: today) - 1) % 3 * -1 start = calendar.date(byAdding: .month, value: monthOffset - 3, to: firstDayOfMonth)! end = calendar.date(byAdding: .month, value: 3, to: start)!.addingTimeInterval(-1) } else if (dateRange == "this_year") { start = firstDayOfYear end = calendar.date(byAdding: .year, value: 1, to: start)!.addingTimeInterval(-1) } else if (dateRange == "last_year") { start = calendar.date(byAdding: .year, value: -1, to: firstDayOfYear)! end = calendar.date(byAdding: .year, value: 1, to: start)!.addingTimeInterval(-1) } return (dateFormatter.string(from: start), dateFormatter.string(from: end)) } } struct WidgetData: Decodable, Hashable { let url: String let companyId: String let companies: [String: WidgetCompany] let dateRanges: [String: String] enum CodingKeys: String, CodingKey { case url case companyId = "company_id" case companies case dateRanges = "date_ranges" } } struct WidgetCompany: Decodable, Hashable { let id: String let name: String let token: String let firstMonthOfYear: Int let accentColor: String let currencyId: String let currencies: [String: WidgetCurrency] enum CodingKeys: String, CodingKey { case id case name case token case firstMonthOfYear = "first_month_of_year" case accentColor = "accent_color" case currencyId = "currency_id" case currencies } } struct WidgetCurrency: Decodable, Hashable { let id: String let name: String let code: String let exchangeRate: Double enum CodingKeys: String, CodingKey { case id case name case code case exchangeRate = "exchange_rate" } } struct SimpleEntry: TimelineEntry { let date: Date let configuration: ConfigurationIntent let widgetData: WidgetData? let field: String let value: String } struct DashboardWidgetEntryView : View { @Environment(\.colorScheme) var colorScheme var entry: Provider.Entry var accentColor: Color { let companyId = entry.configuration.company?.identifier ?? "" return Color(hex: (entry.widgetData?.companies[companyId]!.accentColor)!) } var body: some View { ZStack { Rectangle().fill(accentColor) VStack(alignment: .leading) { HStack { VStack { Text(entry.field) .font(.body) .bold() .lineLimit(2) .multilineTextAlignment(.center) .foregroundColor(accentColor) Text(entry.value) .font(.title) .privacySensitive() .lineLimit(2) .minimumScaleFactor(0.8) } .padding(.all) } .padding([.top, .bottom], 8) .background(ContainerRelativeShape().fill(Color(colorScheme == .dark ? .black : .white))) Spacer() Text(entry.configuration.company?.displayString ?? "") .font(.body) .bold() .foregroundColor(Color.white) Text(entry.configuration.dateRange?.displayString ?? "") .font(.caption) .foregroundColor(Color.white) } .padding(.all) } } } @main struct DashboardWidget: Widget { let kind: String = "DashboardWidget" var body: some WidgetConfiguration { IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in DashboardWidgetEntryView(entry: entry) } .configurationDisplayName("Dashboard") .description("View total from dashboard.") .supportedFamilies([.systemSmall,]) } } struct DashboardWidget_Previews: PreviewProvider { static var previews: some View { let entry = SimpleEntry(date: Date(), configuration: ConfigurationIntent(), widgetData: WidgetData(url: "url", companyId: "", companies: [:], dateRanges: [:]), field: "Active Invoices", value: "$100.00") DashboardWidgetEntryView(entry: entry) .previewContext(WidgetPreviewContext(family: .systemSmall)) //.environment(\.sizeCategory, .extraLarge) //.environment(\.colorScheme, .dark) } } struct ApiResult: Codable { let invoices: Invoices? let revenue: Revenue? let outstanding: Outstanding? let expenses: Expenses? } struct Invoices: Codable { let invoicedAmount: String? let currencyId: String? let code: String? enum CodingKeys: String, CodingKey { case invoicedAmount = "invoiced_amount" case currencyId = "currency_id" case code } } struct Revenue: Codable { let paidToDate: String? let currencyId: String? let code: String? enum CodingKeys: String, CodingKey { case paidToDate = "paid_to_date" case currencyId = "currency_id" case code } } struct Outstanding: Codable { let amount: String? let outstandingCount: Int? let currencyId: String? let code: String? enum CodingKeys: String, CodingKey { case amount case outstandingCount = "outstanding_count" case currencyId = "currency_id" case code } } struct Expenses: Codable { let amount: String? let currencyId: String? let code: String? enum CodingKeys: String, CodingKey { case amount case currencyId = "currency_id" case code } } struct ApiService { static func post(urlString: String, apiToken: String, startDate: String, endDate: String) async throws -> [String: ApiResult]? { let url = URL(string: "\(urlString)?start_date=\(startDate)&end_date=\(endDate)")! var request = URLRequest(url: url) request.httpMethod = "POST" request.addValue(apiToken, forHTTPHeaderField: "X-API-Token") request.addValue("macOS Widget", forHTTPHeaderField: "X-CLIENT") do { let (data, _) = try await URLSession.shared.data(for: request) //print("## Details WAS: \(String(describing: String(data: data, encoding: .utf8)))") //print("## Details IS: \(String(describing: String(data: try! ApiService.fixData(data: data), encoding: .utf8)))") //let result = try JSONDecoder().decode([String: ApiResult].self, from: data) let result = try JSONDecoder().decode([String: ApiResult].self, from: ApiService.fixData(data: data)) return result } catch { print("Error: \(error)") } return nil } static func fixData(data: Data) throws -> Data { var dataString = String(data: data, encoding: .utf8)! if let range = dataString.range(of: "\"currencies\":\\{[^\\}]*?\\},", options: .regularExpression) { dataString.removeSubrange(range) } if let range = dataString.range(of: "\"start_date\":[^\\}]*?,", options: .regularExpression) { dataString.removeSubrange(range) } if let range = dataString.range(of: "\"end_date\":[^\\}]*?,", options: .regularExpression) { dataString.removeSubrange(range) } return dataString.data(using: .utf8)! } }