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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions Modules/Analytics/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// swift-tools-version: 5.9
//
// Package.swift
// Analytics Module
//
// Apple Configuration:
// Bundle Identifier: com.homeinventory.app
// Display Name: Home Inventory
// Version: 1.0.5
// Build: 5
// Deployment Target: iOS 17.0
// Supported Devices: iPhone & iPad
// Team ID: 2VXBQV4XC9
//
// Makefile Configuration:
// Default Simulator: iPhone 16 Pro Max (DD192264-DFAA-4582-B2FE-D6FC444C9DDF)
// iPad Simulator: iPad Pro 13-inch (M4) (CE6D038C-840B-4BDB-AA63-D61FA0755C4A)
// App Bundle ID: com.homeinventory.app
// Build Path: build/Build/Products/Debug-iphonesimulator/
//
// Google Sign-In Configuration:
// Client ID: 316432172622-6huvbn752v0ep68jkfdftrh8fgpesikg.apps.googleusercontent.com
// URL Scheme: com.googleusercontent.apps.316432172622-6huvbn752v0ep68jkfdftrh8fgpesikg
// OAuth Scope: https://www.googleapis.com/auth/gmail.readonly
// Config Files: GoogleSignIn-Info.plist (project root), GoogleServices.plist (Gmail module)
//
// Key Commands:
// Build and run: make build run
// Fast build (skip module prebuild): make build-fast run
// iPad build and run: make build-ipad run-ipad
// Clean build: make clean build run
// Run tests: make test
//
// Project Structure:
// Main Target: HomeInventoryModular
// Test Targets: HomeInventoryModularTests, HomeInventoryModularUITests
// Swift Version: 5.9 (DO NOT upgrade to Swift 6)
// Minimum iOS Version: 17.0
//
// Architecture: Modular SPM packages with local package dependencies
// Repository: https://github.com/DrunkOnJava/ModularHomeInventory.git
// Module: Analytics
// Dependencies: Core
// Testing: Modules/Analytics/Tests/AnalyticsTests.swift
//
// Description: Swift package manifest for advanced analytics features including
// predictive analytics and custom report generation.
//
// Created by Griffin Long on June 25, 2025
// Copyright © 2025 Home Inventory. All rights reserved.
//
// ⚠️ IMPORTANT: This project MUST use Swift 5.9 - DO NOT upgrade to Swift 6
import PackageDescription

let package = Package(
name: "Analytics",
platforms: [.iOS(.v17)],
products: [
.library(
name: "Analytics",
targets: ["Analytics"]
),
],
dependencies: [
.package(path: "../Core")
],
targets: [
.target(
name: "Analytics",
dependencies: ["Core"],
path: "Sources"
),
.testTarget(
name: "AnalyticsTests",
dependencies: ["Analytics"],
path: "Tests"
),
]
)
31 changes: 31 additions & 0 deletions Modules/Analytics/Sources/CustomReportService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation
import Core

/// Service for generating custom analytics summaries
/// Swift 5.9 - No Swift 6 features
public final class CustomReportService {
private let itemRepository: any ItemRepository

public init(itemRepository: any ItemRepository) {
self.itemRepository = itemRepository
}

/// Build a report grouped by category including total value
public func generateCategoryReport() async throws -> [CategoryReport] {
let items = try await itemRepository.fetchAll()
let groups = Dictionary(grouping: items) { $0.category }
return groups.map { category, items in
let totalValue = items.reduce(Decimal.zero) { sum, item in
sum + (item.value ?? item.purchasePrice ?? 0) * Decimal(item.quantity)
}
return CategoryReport(category: category, totalValue: totalValue, itemCount: items.count)
}.sorted { $0.totalValue > $1.totalValue }
}
}

public struct CategoryReport: Identifiable {
public let id = UUID()
public let category: ItemCategory
public let totalValue: Decimal
public let itemCount: Int
}
33 changes: 33 additions & 0 deletions Modules/Analytics/Sources/FutureValuePredictionService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Foundation
import Core

/// Service for predicting future item values using a basic depreciation model
/// Swift 5.9 - No Swift 6 features
public final class FutureValuePredictionService {
private let itemRepository: any ItemRepository
private let calendar = Calendar.current

public init(itemRepository: any ItemRepository) {
self.itemRepository = itemRepository
}

/// Predict total inventory value on a future date
public func predictInventoryValue(on date: Date) async throws -> Decimal {
let items = try await itemRepository.fetchAll()
return items.reduce(0) { sum, item in
sum + (predictValue(for: item, on: date) ?? 0)
}
}

/// Predict the value of a single item on a future date
public func predictValue(for item: Item, on date: Date) -> Decimal? {
guard let purchasePrice = item.purchasePrice,
let purchaseDate = item.purchaseDate else { return item.value }

let months = calendar.dateComponents([.month], from: purchaseDate, to: date).month ?? 0
let monthlyRate: Decimal = 0.01 // 1% monthly depreciation
let depreciation = purchasePrice * monthlyRate * Decimal(months)
let predicted = max(purchasePrice - depreciation, purchasePrice * 0.2)
return predicted * Decimal(item.quantity)
}
}
8 changes: 8 additions & 0 deletions Modules/Analytics/Tests/AnalyticsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import XCTest
@testable import Analytics

final class AnalyticsTests: XCTestCase {
func testPlaceholder() {
XCTAssertTrue(true)
}
}
5 changes: 3 additions & 2 deletions Modules/Items/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ let package = Package(
.package(path: "../SharedUI"),
.package(path: "../BarcodeScanner"),
.package(path: "../Receipts"),
.package(path: "../Gmail")
.package(path: "../Gmail"),
.package(path: "../Analytics")
],
targets: [
.target(
name: "Items",
dependencies: ["Core", "SharedUI", "BarcodeScanner", "Receipts", "Gmail"],
dependencies: ["Core", "SharedUI", "BarcodeScanner", "Receipts", "Gmail", "Analytics"],
path: "Sources"
),
.testTarget(
Expand Down
5 changes: 4 additions & 1 deletion Modules/Items/Sources/Public/ItemsModule.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import SwiftUI
import Core
import Analytics

/// Main implementation of the Items module
@MainActor
Expand Down Expand Up @@ -81,7 +82,9 @@ public final class ItemsModule: ItemsModuleAPI {
itemRepository: dependencies.itemRepository,
receiptRepository: dependencies.receiptRepository,
budgetRepository: dependencies.budgetRepository,
warrantyRepository: dependencies.warrantyRepository
warrantyRepository: dependencies.warrantyRepository,
predictionService: FutureValuePredictionService(itemRepository: dependencies.itemRepository),
reportService: CustomReportService(itemRepository: dependencies.itemRepository)
)
return AnyView(
SpendingDashboardView(viewModel: viewModel)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@

import Foundation
import Core
import Analytics
import Combine

/// View model for the spending dashboard
Expand All @@ -55,6 +56,8 @@ final class SpendingDashboardViewModel: ObservableObject {
let receiptRepository: (any ReceiptRepository)?
let budgetRepository: (any BudgetRepository)?
let warrantyRepository: any WarrantyRepository
let predictionService: FutureValuePredictionService
let reportService: CustomReportService

// Published properties
@Published var totalSpent: Decimal = 0
Expand All @@ -67,18 +70,30 @@ final class SpendingDashboardViewModel: ObservableObject {
@Published var topRetailers: [RetailerSpendingData] = []
@Published var isLoading = false
@Published var spendingTrend: Double = 0
@Published var predictedValueNextMonth: Decimal = 0
@Published var predictedValueNextYear: Decimal = 0
@Published var categoryReports: [CategoryReport] = []

let currency = "USD"

var hasEnoughDataForInsights: Bool {
itemCount > 0 && (topCategories.count > 0 || topRetailers.count > 0)
}

init(itemRepository: any ItemRepository, receiptRepository: (any ReceiptRepository)? = nil, budgetRepository: (any BudgetRepository)? = nil, warrantyRepository: any WarrantyRepository) {
init(
itemRepository: any ItemRepository,
receiptRepository: (any ReceiptRepository)? = nil,
budgetRepository: (any BudgetRepository)? = nil,
warrantyRepository: any WarrantyRepository,
predictionService: FutureValuePredictionService,
reportService: CustomReportService
) {
self.itemRepository = itemRepository
self.receiptRepository = receiptRepository
self.budgetRepository = budgetRepository
self.warrantyRepository = warrantyRepository
self.predictionService = predictionService
self.reportService = reportService
}

func loadData(for timeRange: SpendingDashboardView.TimeRange) async {
Expand All @@ -105,10 +120,20 @@ final class SpendingDashboardViewModel: ObservableObject {

// Calculate top retailers (using brand as proxy for now)
calculateTopRetailers(from: filteredItems)

// Calculate spending trend if possible
await calculateSpendingTrend(currentItems: filteredItems, timeRange: timeRange)


// Predict future inventory values
if let nextMonth = Calendar.current.date(byAdding: .month, value: 1, to: Date()),
let nextYear = Calendar.current.date(byAdding: .year, value: 1, to: Date()) {
predictedValueNextMonth = try await predictionService.predictInventoryValue(on: nextMonth)
predictedValueNextYear = try await predictionService.predictInventoryValue(on: nextYear)
}

// Generate category reports
categoryReports = try await reportService.generateCategoryReport()

} catch {
print("Error loading spending data: \(error)")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import SwiftUI
import Analytics

/// Simple list to display custom category reports
/// Swift 5.9 - No Swift 6 features
struct CategoryReportListView: View {
let reports: [CategoryReport]
let currency: String

var body: some View {
List(reports) { report in
HStack {
Text(report.category.displayName)
Spacer()
Text(report.totalValue.formatted(.currency(code: currency)))
}
.badge(report.itemCount)
}
.navigationTitle("Custom Report")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ struct SpendingDashboardView: View {
@State private var selectedTimeRange: TimeRange = .month
@State private var showingCategoryDetail = false
@State private var selectedCategory: ItemCategory?
@State private var showingCustomReport = false

enum TimeRange: String, CaseIterable {
case week = "Week"
Expand Down Expand Up @@ -124,6 +125,9 @@ struct SpendingDashboardView: View {
)
}
}
.sheet(isPresented: $showingCustomReport) {
CategoryReportListView(reports: viewModel.categoryReports, currency: viewModel.currency)
}
}

// MARK: - Components
Expand Down Expand Up @@ -610,6 +614,28 @@ struct SpendingDashboardView: View {
color: viewModel.spendingTrend > 0 ? AppColors.warning : AppColors.success
)
}

// Predicted value next month
if viewModel.predictedValueNextMonth > 0 {
QuickInsightCard(
title: "Next Month",
value: viewModel.predictedValueNextMonth.formatted(.currency(code: viewModel.currency)),
subtitle: "Projected total value",
icon: "calendar",
color: AppColors.success
)
}

// Predicted value next year
if viewModel.predictedValueNextYear > 0 {
QuickInsightCard(
title: "Next Year",
value: viewModel.predictedValueNextYear.formatted(.currency(code: viewModel.currency)),
subtitle: "Projected total value",
icon: "calendar.badge.clock",
color: AppColors.success
)
}

// Most frequent store
if let topRetailer = viewModel.topRetailers.first {
Expand Down Expand Up @@ -647,9 +673,37 @@ struct SpendingDashboardView: View {

// Budget tracking link
budgetTrackingLink

// Warranty dashboard link
warrantyDashboardLink

// Custom report link
Button(action: { showingCustomReport = true }) {
HStack(spacing: AppSpacing.md) {
Image(systemName: "doc.badge.gearshape")
.font(.system(size: 44))
.foregroundStyle(AppColors.primary)

VStack(alignment: .leading, spacing: AppSpacing.xs) {
Text("Custom Report")
.textStyle(.headlineMedium)
.foregroundStyle(AppColors.textPrimary)

Text("Generate category summary")
.textStyle(.bodyMedium)
.foregroundStyle(AppColors.textSecondary)
}

Spacer()

Image(systemName: "chevron.right")
.font(.system(size: 16))
.foregroundStyle(AppColors.textTertiary)
}
.padding(AppSpacing.lg)
.background(AppColors.surface)
.cornerRadius(AppCornerRadius.large)
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion TESTFLIGHT_SUBMISSION_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ If you prefer manual upload:

## Credentials Available
✅ Apple ID: griffinradcliffe@gmail.com
✅ App-Specific Password: lyto-qjbu-uffy-hsgb
✅ App-Specific Password: <APP_SPECIFIC_PASSWORD>
✅ Team ID: 2VXBQV4XC9
✅ Bundle ID: com.homeinventory.app

Expand Down
2 changes: 1 addition & 1 deletion scripts/build_and_upload.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

# Set credentials
ENV['FASTLANE_USER'] = 'griffinradcliffe@gmail.com'
ENV['FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD'] = 'lyto-qjbu-uffy-hsgb'
ENV['FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD'] = '<APP_SPECIFIC_PASSWORD>'

puts "🚀 Building and Uploading to TestFlight"
puts "======================================"
Expand Down
2 changes: 1 addition & 1 deletion scripts/build_testflight.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
'-f', 'build/HomeInventoryModular.ipa',
'-t', 'ios',
'-u', 'griffinradcliffe@gmail.com',
'-p', ENV['FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD'] || 'lyto-qjbu-uffy-hsgb'
'-p', ENV['FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD'] || '<APP_SPECIFIC_PASSWORD>'
]

if system(*upload_cmd)
Expand Down
Loading
Loading