P1: Shared FixtureDataManager (empty + populated) for cross-platform snapshots

InMemoryDataManager + Fixtures with deterministic data (fixed clock 2026-04-15,
2 residences, 8 tasks, 3 contractors, 5 documents). FixtureDataManager.empty()
and .populated() factories. Exposed to Swift via SKIE.

Expanded IDataManager surface (5 -> 22 members) so fixtures cover every
StateFlow and lookup helper screens read: myResidences, allTasks,
tasksByResidence, documents, documentsByResidence, contractors, residenceTypes,
taskFrequencies, taskPriorities, taskCategories, contractorSpecialties,
taskTemplates, taskTemplatesGrouped, residenceSummaries, upgradeTriggers,
promotions, plus get{ResidenceType,TaskFrequency,TaskPriority,TaskCategory,
ContractorSpecialty}(id) lookup helpers. DataManager implementation is a pure
override-keyword addition — no behavior change.

Enables P2 (Android gallery) + P3 (iOS gallery) to render real screens against
identical inputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 19:22:41 -05:00
parent c57743dca0
commit 47eaf5a0c0
6 changed files with 1263 additions and 31 deletions
@@ -125,35 +125,35 @@ object DataManager : IDataManager {
override val residences: StateFlow<List<Residence>> = _residences.asStateFlow()
private val _myResidences = MutableStateFlow<MyResidencesResponse?>(null)
val myResidences: StateFlow<MyResidencesResponse?> = _myResidences.asStateFlow()
override val myResidences: StateFlow<MyResidencesResponse?> = _myResidences.asStateFlow()
private val _totalSummary = MutableStateFlow<TotalSummary?>(null)
override val totalSummary: StateFlow<TotalSummary?> = _totalSummary.asStateFlow()
private val _residenceSummaries = MutableStateFlow<Map<Int, ResidenceSummaryResponse>>(emptyMap())
val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>> = _residenceSummaries.asStateFlow()
override val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>> = _residenceSummaries.asStateFlow()
// ==================== TASKS ====================
private val _allTasks = MutableStateFlow<TaskColumnsResponse?>(null)
val allTasks: StateFlow<TaskColumnsResponse?> = _allTasks.asStateFlow()
override val allTasks: StateFlow<TaskColumnsResponse?> = _allTasks.asStateFlow()
private val _tasksByResidence = MutableStateFlow<Map<Int, TaskColumnsResponse>>(emptyMap())
val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>> = _tasksByResidence.asStateFlow()
override val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>> = _tasksByResidence.asStateFlow()
// ==================== DOCUMENTS ====================
private val _documents = MutableStateFlow<List<Document>>(emptyList())
val documents: StateFlow<List<Document>> = _documents.asStateFlow()
override val documents: StateFlow<List<Document>> = _documents.asStateFlow()
private val _documentsByResidence = MutableStateFlow<Map<Int, List<Document>>>(emptyMap())
val documentsByResidence: StateFlow<Map<Int, List<Document>>> = _documentsByResidence.asStateFlow()
override val documentsByResidence: StateFlow<Map<Int, List<Document>>> = _documentsByResidence.asStateFlow()
// ==================== CONTRACTORS ====================
// Stores ContractorSummary for list views (lighter weight than full Contractor)
private val _contractors = MutableStateFlow<List<ContractorSummary>>(emptyList())
val contractors: StateFlow<List<ContractorSummary>> = _contractors.asStateFlow()
override val contractors: StateFlow<List<ContractorSummary>> = _contractors.asStateFlow()
// ==================== SUBSCRIPTION ====================
@@ -161,39 +161,39 @@ object DataManager : IDataManager {
override val subscription: StateFlow<SubscriptionStatus?> = _subscription.asStateFlow()
private val _upgradeTriggers = MutableStateFlow<Map<String, UpgradeTriggerData>>(emptyMap())
val upgradeTriggers: StateFlow<Map<String, UpgradeTriggerData>> = _upgradeTriggers.asStateFlow()
override val upgradeTriggers: StateFlow<Map<String, UpgradeTriggerData>> = _upgradeTriggers.asStateFlow()
private val _featureBenefits = MutableStateFlow<List<FeatureBenefit>>(emptyList())
override val featureBenefits: StateFlow<List<FeatureBenefit>> = _featureBenefits.asStateFlow()
private val _promotions = MutableStateFlow<List<Promotion>>(emptyList())
val promotions: StateFlow<List<Promotion>> = _promotions.asStateFlow()
override val promotions: StateFlow<List<Promotion>> = _promotions.asStateFlow()
// ==================== LOOKUPS (Reference Data) ====================
// List-based for dropdowns/pickers
private val _residenceTypes = MutableStateFlow<List<ResidenceType>>(emptyList())
val residenceTypes: StateFlow<List<ResidenceType>> = _residenceTypes.asStateFlow()
override val residenceTypes: StateFlow<List<ResidenceType>> = _residenceTypes.asStateFlow()
private val _taskFrequencies = MutableStateFlow<List<TaskFrequency>>(emptyList())
val taskFrequencies: StateFlow<List<TaskFrequency>> = _taskFrequencies.asStateFlow()
override val taskFrequencies: StateFlow<List<TaskFrequency>> = _taskFrequencies.asStateFlow()
private val _taskPriorities = MutableStateFlow<List<TaskPriority>>(emptyList())
val taskPriorities: StateFlow<List<TaskPriority>> = _taskPriorities.asStateFlow()
override val taskPriorities: StateFlow<List<TaskPriority>> = _taskPriorities.asStateFlow()
private val _taskCategories = MutableStateFlow<List<TaskCategory>>(emptyList())
val taskCategories: StateFlow<List<TaskCategory>> = _taskCategories.asStateFlow()
override val taskCategories: StateFlow<List<TaskCategory>> = _taskCategories.asStateFlow()
private val _contractorSpecialties = MutableStateFlow<List<ContractorSpecialty>>(emptyList())
val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = _contractorSpecialties.asStateFlow()
override val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = _contractorSpecialties.asStateFlow()
// ==================== TASK TEMPLATES ====================
private val _taskTemplates = MutableStateFlow<List<TaskTemplate>>(emptyList())
val taskTemplates: StateFlow<List<TaskTemplate>> = _taskTemplates.asStateFlow()
override val taskTemplates: StateFlow<List<TaskTemplate>> = _taskTemplates.asStateFlow()
private val _taskTemplatesGrouped = MutableStateFlow<TaskTemplatesGroupedResponse?>(null)
val taskTemplatesGrouped: StateFlow<TaskTemplatesGroupedResponse?> = _taskTemplatesGrouped.asStateFlow()
override val taskTemplatesGrouped: StateFlow<TaskTemplatesGroupedResponse?> = _taskTemplatesGrouped.asStateFlow()
// Map-based for O(1) ID resolution
private val _residenceTypesMap = MutableStateFlow<Map<Int, ResidenceType>>(emptyMap())
@@ -261,11 +261,11 @@ object DataManager : IDataManager {
// ==================== O(1) LOOKUP HELPERS ====================
fun getResidenceType(id: Int?): ResidenceType? = id?.let { _residenceTypesMap.value[it] }
fun getTaskFrequency(id: Int?): TaskFrequency? = id?.let { _taskFrequenciesMap.value[it] }
fun getTaskPriority(id: Int?): TaskPriority? = id?.let { _taskPrioritiesMap.value[it] }
fun getTaskCategory(id: Int?): TaskCategory? = id?.let { _taskCategoriesMap.value[it] }
fun getContractorSpecialty(id: Int?): ContractorSpecialty? = id?.let { _contractorSpecialtiesMap.value[it] }
override fun getResidenceType(id: Int?): ResidenceType? = id?.let { _residenceTypesMap.value[it] }
override fun getTaskFrequency(id: Int?): TaskFrequency? = id?.let { _taskFrequenciesMap.value[it] }
override fun getTaskPriority(id: Int?): TaskPriority? = id?.let { _taskPrioritiesMap.value[it] }
override fun getTaskCategory(id: Int?): TaskCategory? = id?.let { _taskCategoriesMap.value[it] }
override fun getContractorSpecialty(id: Int?): ContractorSpecialty? = id?.let { _contractorSpecialtiesMap.value[it] }
// ==================== AUTH UPDATE METHODS ====================
@@ -1,37 +1,110 @@
package com.tt.honeyDue.data
import com.tt.honeyDue.models.ContractorSpecialty
import com.tt.honeyDue.models.ContractorSummary
import com.tt.honeyDue.models.Document
import com.tt.honeyDue.models.FeatureBenefit
import com.tt.honeyDue.models.MyResidencesResponse
import com.tt.honeyDue.models.Promotion
import com.tt.honeyDue.models.Residence
import com.tt.honeyDue.models.ResidenceSummaryResponse
import com.tt.honeyDue.models.ResidenceType
import com.tt.honeyDue.models.SubscriptionStatus
import com.tt.honeyDue.models.TaskCategory
import com.tt.honeyDue.models.TaskColumnsResponse
import com.tt.honeyDue.models.TaskFrequency
import com.tt.honeyDue.models.TaskPriority
import com.tt.honeyDue.models.TaskTemplate
import com.tt.honeyDue.models.TaskTemplatesGroupedResponse
import com.tt.honeyDue.models.TotalSummary
import com.tt.honeyDue.models.UpgradeTriggerData
import com.tt.honeyDue.models.User
import kotlinx.coroutines.flow.StateFlow
/**
* Minimal contract covering the [DataManager] surface consumed by Compose screens.
* Contract covering the [DataManager] surface consumed by Compose screens
* and the parity-gallery fixture factories.
*
* This interface exists solely so screens can depend on an abstraction that tests,
* previews, and the parity-gallery can substitute via [LocalDataManager]. It is
* deliberately narrow — only members referenced from the ui/screens package tree
* are included.
* This interface exists so screens can depend on an abstraction that tests,
* previews, and the parity-gallery can substitute via [LocalDataManager].
* The member set intentionally mirrors every StateFlow and lookup helper a
* screen may read — [com.tt.honeyDue.testing.FixtureDataManager] produces
* fully-populated fakes so snapshot renders have the data every surface
* expects without reaching into the production singleton.
*
* ViewModels, [com.tt.honeyDue.network.APILayer], and [PersistenceManager] continue
* to use the concrete [DataManager] singleton directly; widening this interface to
* cover their surface is explicitly out of scope for the ambient refactor.
* ViewModels, [com.tt.honeyDue.network.APILayer], and [PersistenceManager]
* continue to use the concrete [DataManager] singleton directly; they are
* not covered by this interface.
*/
interface IDataManager {
// ==================== AUTH ====================
/** Observed by [com.tt.honeyDue.ui.screens.ProfileScreen], [com.tt.honeyDue.ui.screens.ResidenceDetailScreen], [com.tt.honeyDue.ui.screens.ResidenceFormScreen]. */
val currentUser: StateFlow<User?>
// ==================== RESIDENCES ====================
/** Observed by [com.tt.honeyDue.ui.screens.ContractorDetailScreen] and the onboarding first-task screen. */
val residences: StateFlow<List<Residence>>
/** Full my-residences API response (may include metadata beyond the list itself). */
val myResidences: StateFlow<MyResidencesResponse?>
/** Observed by [com.tt.honeyDue.ui.screens.HomeScreen] and [com.tt.honeyDue.ui.screens.ResidencesScreen]. */
val totalSummary: StateFlow<TotalSummary?>
/** Per-residence summary cache (keyed by residence id). */
val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>>
// ==================== TASKS ====================
/** Kanban board covering all tasks across all residences. */
val allTasks: StateFlow<TaskColumnsResponse?>
/** Kanban board cache keyed by residence id. */
val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>>
// ==================== DOCUMENTS ====================
val documents: StateFlow<List<Document>>
val documentsByResidence: StateFlow<Map<Int, List<Document>>>
// ==================== CONTRACTORS ====================
val contractors: StateFlow<List<ContractorSummary>>
// ==================== SUBSCRIPTION ====================
/** Observed by [com.tt.honeyDue.ui.screens.ProfileScreen]. */
val subscription: StateFlow<SubscriptionStatus?>
val upgradeTriggers: StateFlow<Map<String, UpgradeTriggerData>>
/** Observed by [com.tt.honeyDue.ui.screens.subscription.FeatureComparisonScreen]. */
val featureBenefits: StateFlow<List<FeatureBenefit>>
/** Observed by [com.tt.honeyDue.ui.screens.ProfileScreen]. */
val subscription: StateFlow<SubscriptionStatus?>
val promotions: StateFlow<List<Promotion>>
// ==================== LOOKUPS ====================
val residenceTypes: StateFlow<List<ResidenceType>>
val taskFrequencies: StateFlow<List<TaskFrequency>>
val taskPriorities: StateFlow<List<TaskPriority>>
val taskCategories: StateFlow<List<TaskCategory>>
val contractorSpecialties: StateFlow<List<ContractorSpecialty>>
// ==================== TASK TEMPLATES ====================
val taskTemplates: StateFlow<List<TaskTemplate>>
val taskTemplatesGrouped: StateFlow<TaskTemplatesGroupedResponse?>
// ==================== O(1) LOOKUP HELPERS ====================
fun getResidenceType(id: Int?): ResidenceType?
fun getTaskFrequency(id: Int?): TaskFrequency?
fun getTaskPriority(id: Int?): TaskPriority?
fun getTaskCategory(id: Int?): TaskCategory?
fun getContractorSpecialty(id: Int?): ContractorSpecialty?
}
@@ -0,0 +1,98 @@
package com.tt.honeyDue.testing
import com.tt.honeyDue.data.IDataManager
/**
* Factories that produce deterministic [IDataManager] instances for the
* parity-gallery (Android Roborazzi + iOS swift-snapshot-testing) and any
* other snapshot/preview harness. Both platforms consume the same fixture
* graph (via SKIE bridging on iOS), so any layout divergence between iOS
* and Android renders of the same screen is a real parity bug — not a test
* data mismatch.
*
* Use:
* ```kotlin
* // Android Compose preview or Roborazzi test
* CompositionLocalProvider(LocalDataManager provides FixtureDataManager.empty()) {
* MyScreen()
* }
* ```
*
* ```swift
* // iOS SwiftUI preview or snapshot test
* MyView().environment(\.dataManager,
* DataManagerObservable(kotlin: FixtureDataManager.shared.populated()))
* ```
*/
object FixtureDataManager {
/**
* Data-free fixture — represents a freshly-signed-in user with no
* residences, no tasks, no contractors, no documents. Lookups
* (priorities, categories, frequencies) are still populated because
* empty-state form pickers render them even before the user has any
* entities of their own.
*/
fun empty(): IDataManager = InMemoryDataManager(
currentUser = null,
residences = emptyList(),
myResidencesResponse = null,
totalSummary = null,
residenceSummaries = emptyMap(),
allTasks = null,
tasksByResidence = emptyMap(),
documents = emptyList(),
documentsByResidence = emptyMap(),
contractors = emptyList(),
subscription = Fixtures.freeSubscription,
upgradeTriggers = emptyMap(),
featureBenefits = Fixtures.featureBenefits,
promotions = emptyList(),
residenceTypes = Fixtures.residenceTypes,
taskFrequencies = Fixtures.taskFrequencies,
taskPriorities = Fixtures.taskPriorities,
taskCategories = Fixtures.taskCategories,
contractorSpecialties = Fixtures.contractorSpecialties,
taskTemplates = Fixtures.taskTemplates,
taskTemplatesGrouped = Fixtures.taskTemplatesGrouped,
)
/**
* Fully-populated fixture with realistic content for every screen:
* 2 residences · 8 tasks (mix of overdue/due-soon/upcoming/completed)
* · 3 contractors · 5 documents (2 warranties — one expired —
* + 3 manuals). The user is premium-tier so gated surfaces render
* their "pro" appearance.
*/
fun populated(): IDataManager = InMemoryDataManager(
currentUser = Fixtures.user,
residences = Fixtures.residences,
myResidencesResponse = Fixtures.myResidencesResponse,
totalSummary = Fixtures.totalSummary,
residenceSummaries = Fixtures.residenceSummaries,
allTasks = Fixtures.taskColumnsResponse,
tasksByResidence = Fixtures.residences.associate { residence ->
residence.id to Fixtures.taskColumnsResponse.copy(
columns = Fixtures.taskColumnsResponse.columns.map { column ->
val filtered = column.tasks.filter { it.residenceId == residence.id }
column.copy(tasks = filtered, count = filtered.size)
},
residenceId = residence.id.toString(),
)
},
documents = Fixtures.documents,
documentsByResidence = Fixtures.documentsByResidence,
contractors = Fixtures.contractorSummaries,
subscription = Fixtures.premiumSubscription,
upgradeTriggers = emptyMap(),
featureBenefits = Fixtures.featureBenefits,
promotions = emptyList(),
residenceTypes = Fixtures.residenceTypes,
taskFrequencies = Fixtures.taskFrequencies,
taskPriorities = Fixtures.taskPriorities,
taskCategories = Fixtures.taskCategories,
contractorSpecialties = Fixtures.contractorSpecialties,
taskTemplates = Fixtures.taskTemplates,
taskTemplatesGrouped = Fixtures.taskTemplatesGrouped,
)
}
@@ -0,0 +1,734 @@
package com.tt.honeyDue.testing
import com.tt.honeyDue.models.Contractor
import com.tt.honeyDue.models.ContractorSpecialty
import com.tt.honeyDue.models.ContractorSummary
import com.tt.honeyDue.models.Document
import com.tt.honeyDue.models.FeatureBenefit
import com.tt.honeyDue.models.MyResidencesResponse
import com.tt.honeyDue.models.Residence
import com.tt.honeyDue.models.ResidenceSummaryResponse
import com.tt.honeyDue.models.ResidenceType
import com.tt.honeyDue.models.ResidenceUserResponse
import com.tt.honeyDue.models.SubscriptionStatus
import com.tt.honeyDue.models.TaskCategory
import com.tt.honeyDue.models.TaskColumn
import com.tt.honeyDue.models.TaskColumnsResponse
import com.tt.honeyDue.models.TaskFrequency
import com.tt.honeyDue.models.TaskPriority
import com.tt.honeyDue.models.TaskResponse
import com.tt.honeyDue.models.TaskTemplate
import com.tt.honeyDue.models.TaskTemplateCategoryGroup
import com.tt.honeyDue.models.TaskTemplatesGroupedResponse
import com.tt.honeyDue.models.TaskUserResponse
import com.tt.honeyDue.models.TierLimits
import com.tt.honeyDue.models.TotalSummary
import com.tt.honeyDue.models.UsageStats
import com.tt.honeyDue.models.User
import com.tt.honeyDue.models.toSummary
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.minus
import kotlinx.datetime.plus
/**
* Deterministic fixture graph for the parity-gallery and snapshot tests.
*
* Every date is derived from [FIXED_DATE] (2026-04-15) — no `Clock.System.now()`
* calls — so identical fixtures render identically every run on both
* platforms. These fixtures feed [FixtureDataManager] which in turn feeds
* `LocalDataManager` (Android) and `@Environment(\.dataManager)` (iOS).
*
* Populated content target (matches `rc-parity-gallery.md` P1):
* - 2 residences (1 primary home + 1 lake cabin)
* - 8 tasks (2 overdue, 3 due this week, 2 due later, 1 completed)
* - 3 contractors (plumber, electrician, HVAC)
* - 5 documents (2 warranties — one expired — + 3 manuals)
*/
object Fixtures {
// ==================== FIXED CLOCK ====================
val FIXED_DATE: LocalDate = LocalDate(2026, 4, 15)
private val FIXED_ISO_TIMESTAMP: String = "2026-04-15T12:00:00Z"
private fun isoDate(d: LocalDate): String = d.toString() // yyyy-MM-dd
private fun daysFromFixed(offset: Int): String =
if (offset == 0) isoDate(FIXED_DATE)
else if (offset > 0) isoDate(FIXED_DATE.plus(offset, DateTimeUnit.DAY))
else isoDate(FIXED_DATE.minus(-offset, DateTimeUnit.DAY))
// ==================== RESIDENCE TYPES ====================
val residenceTypes: List<ResidenceType> = listOf(
ResidenceType(id = 1, name = "House"),
ResidenceType(id = 2, name = "Condo"),
ResidenceType(id = 3, name = "Apartment"),
ResidenceType(id = 4, name = "Cabin"),
ResidenceType(id = 5, name = "Townhouse"),
)
// ==================== TASK CATEGORIES ====================
val taskCategories: List<TaskCategory> = listOf(
TaskCategory(id = 1, name = "Plumbing", description = "Pipes, faucets, drains", icon = "water", color = "#2196F3", displayOrder = 1),
TaskCategory(id = 2, name = "Electrical", description = "Wiring, outlets, fixtures", icon = "bolt", color = "#FFB300", displayOrder = 2),
TaskCategory(id = 3, name = "HVAC", description = "Heating, cooling, ventilation", icon = "thermostat", color = "#00897B", displayOrder = 3),
TaskCategory(id = 4, name = "Exterior", description = "Roof, siding, landscaping", icon = "house", color = "#6D4C41", displayOrder = 4),
TaskCategory(id = 5, name = "Interior", description = "Walls, floors, fixtures", icon = "weekend", color = "#8E24AA", displayOrder = 5),
TaskCategory(id = 6, name = "Appliance", description = "Kitchen & laundry appliances", icon = "kitchen", color = "#E53935", displayOrder = 6),
)
// ==================== TASK PRIORITIES ====================
val taskPriorities: List<TaskPriority> = listOf(
TaskPriority(id = 1, name = "Urgent", level = 4, color = "#EF4444", displayOrder = 1),
TaskPriority(id = 2, name = "High", level = 3, color = "#F59E0B", displayOrder = 2),
TaskPriority(id = 3, name = "Medium", level = 2, color = "#3B82F6", displayOrder = 3),
TaskPriority(id = 4, name = "Low", level = 1, color = "#10B981", displayOrder = 4),
)
// ==================== TASK FREQUENCIES ====================
val taskFrequencies: List<TaskFrequency> = listOf(
TaskFrequency(id = 1, name = "One-time", days = null, displayOrder = 1),
TaskFrequency(id = 2, name = "Monthly", days = 30, displayOrder = 2),
TaskFrequency(id = 3, name = "Quarterly", days = 90, displayOrder = 3),
TaskFrequency(id = 4, name = "Annual", days = 365, displayOrder = 4),
TaskFrequency(id = 5, name = "Custom", days = null, displayOrder = 5),
)
// ==================== CONTRACTOR SPECIALTIES ====================
val contractorSpecialties: List<ContractorSpecialty> = listOf(
ContractorSpecialty(id = 1, name = "Plumbing", description = "Pipes, fixtures, drains", icon = "water", displayOrder = 1),
ContractorSpecialty(id = 2, name = "Electrical", description = "Wiring, panels, fixtures", icon = "bolt", displayOrder = 2),
ContractorSpecialty(id = 3, name = "HVAC", description = "Heating & cooling", icon = "thermostat", displayOrder = 3),
ContractorSpecialty(id = 4, name = "Roofing", description = "Roof repair & replacement", icon = "roof", displayOrder = 4),
ContractorSpecialty(id = 5, name = "General", description = "General handyman", icon = "wrench", displayOrder = 5),
)
// ==================== USER ====================
val user: User = User(
id = 42,
username = "jane.demo",
email = "jane.demo@honeydue.example",
firstName = "Jane",
lastName = "Demo",
isActive = true,
dateJoined = FIXED_ISO_TIMESTAMP,
lastLogin = FIXED_ISO_TIMESTAMP,
)
private val residenceUser: ResidenceUserResponse = ResidenceUserResponse(
id = 42,
username = "jane.demo",
email = "jane.demo@honeydue.example",
firstName = "Jane",
lastName = "Demo",
)
private val taskUser: TaskUserResponse = TaskUserResponse(
id = 42,
username = "jane.demo",
email = "jane.demo@honeydue.example",
firstName = "Jane",
lastName = "Demo",
)
// ==================== RESIDENCES ====================
val primaryHome: Residence = Residence(
id = 1,
ownerId = 42,
owner = residenceUser,
users = listOf(residenceUser),
name = "Primary Home",
propertyTypeId = 1,
propertyType = residenceTypes[0],
streetAddress = "1234 Maple Street",
city = "Madison",
stateProvince = "WI",
postalCode = "53703",
country = "USA",
bedrooms = 3,
bathrooms = 2.5,
squareFootage = 2200,
lotSize = 0.25,
yearBuilt = 2005,
description = "Two-story colonial, attached two-car garage.",
purchaseDate = daysFromFixed(-365 * 4),
purchasePrice = 385_000.0,
isPrimary = true,
isActive = true,
overdueCount = 2,
heatingType = "Gas furnace",
coolingType = "Central AC",
waterHeaterType = "Tankless gas",
roofType = "Asphalt shingle",
hasPool = false,
hasSprinklerSystem = true,
hasSeptic = false,
hasFireplace = true,
hasGarage = true,
hasBasement = true,
hasAttic = true,
exteriorType = "Vinyl siding",
flooringPrimary = "Hardwood",
landscapingType = "Traditional lawn",
createdAt = FIXED_ISO_TIMESTAMP,
updatedAt = FIXED_ISO_TIMESTAMP,
)
val lakeCabin: Residence = Residence(
id = 2,
ownerId = 42,
owner = residenceUser,
users = listOf(residenceUser),
name = "Lake Cabin",
propertyTypeId = 4,
propertyType = residenceTypes[3],
streetAddress = "56 Lakeshore Drive",
city = "Minocqua",
stateProvince = "WI",
postalCode = "54548",
country = "USA",
bedrooms = 2,
bathrooms = 1.0,
squareFootage = 1200,
lotSize = 0.5,
yearBuilt = 1975,
description = "Rustic cabin on the lake — detached shed, no basement.",
purchaseDate = daysFromFixed(-365 * 2),
purchasePrice = 215_000.0,
isPrimary = false,
isActive = true,
overdueCount = 0,
heatingType = "Baseboard electric",
coolingType = "Window units",
waterHeaterType = "Electric tank",
roofType = "Metal",
hasPool = false,
hasSprinklerSystem = false,
hasSeptic = true,
hasFireplace = true,
hasGarage = false,
hasBasement = false,
hasAttic = true,
exteriorType = "Cedar siding",
flooringPrimary = "Pine plank",
landscapingType = "Woodland natural",
createdAt = FIXED_ISO_TIMESTAMP,
updatedAt = FIXED_ISO_TIMESTAMP,
)
val residences: List<Residence> = listOf(primaryHome, lakeCabin)
val myResidencesResponse: MyResidencesResponse = MyResidencesResponse(residences = residences)
val residenceSummaries: Map<Int, ResidenceSummaryResponse> = mapOf(
primaryHome.id to ResidenceSummaryResponse(
id = primaryHome.id,
name = primaryHome.name,
taskCount = 7,
pendingCount = 6,
overdueCount = 2,
),
lakeCabin.id to ResidenceSummaryResponse(
id = lakeCabin.id,
name = lakeCabin.name,
taskCount = 2,
pendingCount = 2,
overdueCount = 0,
),
)
// ==================== TASKS ====================
private fun task(
id: Int,
title: String,
description: String,
residenceId: Int,
priorityId: Int,
categoryId: Int,
frequencyId: Int,
dueDateOffsetDays: Int,
kanbanColumn: String,
completed: Boolean = false,
estimatedCost: Double? = null,
): TaskResponse = TaskResponse(
id = id,
residenceId = residenceId,
createdById = 42,
createdBy = taskUser,
assignedToId = 42,
assignedTo = taskUser,
title = title,
description = description,
categoryId = categoryId,
category = taskCategories.first { it.id == categoryId },
priorityId = priorityId,
priority = taskPriorities.first { it.id == priorityId },
inProgress = false,
frequencyId = frequencyId,
frequency = taskFrequencies.first { it.id == frequencyId },
customIntervalDays = null,
dueDate = daysFromFixed(dueDateOffsetDays),
nextDueDate = if (completed) daysFromFixed(dueDateOffsetDays + 90) else null,
estimatedCost = estimatedCost,
actualCost = null,
contractorId = null,
isCancelled = false,
isArchived = false,
parentTaskId = null,
templateId = null,
completionCount = if (completed) 1 else 0,
kanbanColumn = kanbanColumn,
completions = emptyList(),
createdAt = FIXED_ISO_TIMESTAMP,
updatedAt = FIXED_ISO_TIMESTAMP,
)
// 2 overdue, 3 due this week, 2 due later, 1 completed
val tasks: List<TaskResponse> = listOf(
// Overdue (2)
task(
id = 1,
title = "Replace furnace filter",
description = "Swap the pleated filter and log replacement date on the side.",
residenceId = primaryHome.id,
priorityId = 2,
categoryId = 3,
frequencyId = 3,
dueDateOffsetDays = -5,
kanbanColumn = "overdue_tasks",
estimatedCost = 25.0,
),
task(
id = 2,
title = "Check smoke detector batteries",
description = "All six detectors — replace any older than 12 months.",
residenceId = primaryHome.id,
priorityId = 1,
categoryId = 5,
frequencyId = 4,
dueDateOffsetDays = -2,
kanbanColumn = "overdue_tasks",
estimatedCost = 15.0,
),
// Due this week (3)
task(
id = 3,
title = "Clean gutters",
description = "Clear leaves and debris from all gutters and downspouts.",
residenceId = primaryHome.id,
priorityId = 3,
categoryId = 4,
frequencyId = 3,
dueDateOffsetDays = 3,
kanbanColumn = "due_soon_tasks",
estimatedCost = 120.0,
),
task(
id = 4,
title = "Test sump pump",
description = "Pour water into the pit to verify activation and discharge.",
residenceId = primaryHome.id,
priorityId = 2,
categoryId = 1,
frequencyId = 3,
dueDateOffsetDays = 5,
kanbanColumn = "due_soon_tasks",
),
task(
id = 5,
title = "Reseal deck",
description = "Apply one coat of semi-transparent stain to the main deck.",
residenceId = lakeCabin.id,
priorityId = 4,
categoryId = 4,
frequencyId = 4,
dueDateOffsetDays = 7,
kanbanColumn = "due_soon_tasks",
estimatedCost = 85.0,
),
// Due later (2)
task(
id = 6,
title = "Service HVAC",
description = "Schedule annual service with North Winds HVAC.",
residenceId = primaryHome.id,
priorityId = 3,
categoryId = 3,
frequencyId = 4,
dueDateOffsetDays = 14,
kanbanColumn = "upcoming_tasks",
estimatedCost = 175.0,
),
task(
id = 7,
title = "Power-wash siding",
description = "Rent pressure washer; focus on north wall mildew.",
residenceId = lakeCabin.id,
priorityId = 4,
categoryId = 4,
frequencyId = 4,
dueDateOffsetDays = 21,
kanbanColumn = "upcoming_tasks",
estimatedCost = 60.0,
),
// Completed (1)
task(
id = 8,
title = "Replaced kitchen faucet",
description = "Upgraded to pull-down sprayer model.",
residenceId = primaryHome.id,
priorityId = 3,
categoryId = 1,
frequencyId = 1,
dueDateOffsetDays = -10,
kanbanColumn = "completed_tasks",
completed = true,
estimatedCost = 180.0,
),
)
/** Kanban columns derived from [tasks] — matches API shape. */
val taskColumnsResponse: TaskColumnsResponse = run {
val grouped = tasks.groupBy { it.kanbanColumn ?: "upcoming_tasks" }
val columnSpec = listOf(
Triple("overdue_tasks", "Overdue", "#EF4444"),
Triple("in_progress_tasks", "In Progress", "#F59E0B"),
Triple("due_soon_tasks", "Due Soon", "#3B82F6"),
Triple("upcoming_tasks", "Upcoming", "#6366F1"),
Triple("completed_tasks", "Completed", "#10B981"),
Triple("cancelled_tasks", "Cancelled", "#6B7280"),
)
TaskColumnsResponse(
columns = columnSpec.map { (name, displayName, color) ->
val items = grouped[name].orEmpty()
TaskColumn(
name = name,
displayName = displayName,
buttonTypes = emptyList(),
icons = emptyMap(),
color = color,
tasks = items,
count = items.size,
)
},
daysThreshold = 30,
residenceId = "",
)
}
// ==================== CONTRACTORS ====================
val contractors: List<Contractor> = listOf(
Contractor(
id = 1,
residenceId = primaryHome.id,
createdById = 42,
addedBy = 42,
name = "Marisol Rivera",
company = "Madison Plumbing Co",
phone = "608-555-0101",
email = "info@madisonplumbing.example",
website = "https://madisonplumbing.example",
notes = "Fast response on weekends. Preferred for emergency calls.",
streetAddress = "4421 E Washington Ave",
city = "Madison",
stateProvince = "WI",
postalCode = "53704",
specialties = listOf(contractorSpecialties[0]),
rating = 4.8,
isFavorite = true,
isActive = true,
taskCount = 3,
createdAt = FIXED_ISO_TIMESTAMP,
updatedAt = FIXED_ISO_TIMESTAMP,
),
Contractor(
id = 2,
residenceId = primaryHome.id,
createdById = 42,
addedBy = 42,
name = "Eli Park",
company = "Bright Electric",
phone = "608-555-0202",
email = "contact@brightelectric.example",
website = "https://brightelectric.example",
notes = "Did the panel upgrade in 2024.",
streetAddress = "102 S Park St",
city = "Madison",
stateProvince = "WI",
postalCode = "53715",
specialties = listOf(contractorSpecialties[1]),
rating = 4.5,
isFavorite = false,
isActive = true,
taskCount = 1,
createdAt = FIXED_ISO_TIMESTAMP,
updatedAt = FIXED_ISO_TIMESTAMP,
),
Contractor(
id = 3,
residenceId = primaryHome.id,
createdById = 42,
addedBy = 42,
name = "Dana Thompson",
company = "North Winds HVAC",
phone = "608-555-0303",
email = "service@northwinds.example",
website = "https://northwinds.example",
notes = "Annual service contract renews in October.",
streetAddress = "88 Commerce Ln",
city = "Madison",
stateProvince = "WI",
postalCode = "53719",
specialties = listOf(contractorSpecialties[2]),
rating = 4.7,
isFavorite = true,
isActive = true,
taskCount = 2,
createdAt = FIXED_ISO_TIMESTAMP,
updatedAt = FIXED_ISO_TIMESTAMP,
),
)
val contractorSummaries: List<ContractorSummary> = contractors.map { it.toSummary() }
// ==================== DOCUMENTS ====================
val documents: List<Document> = listOf(
Document(
id = 1,
title = "Furnace Warranty",
documentType = "warranty",
description = "Trane XR80 10-year parts warranty.",
fileName = "trane-warranty.pdf",
fileSize = 245_000,
mimeType = "application/pdf",
modelNumber = "XR80-2019",
serialNumber = "TRN-998112",
vendor = "Trane",
purchaseDate = daysFromFixed(-365 * 7),
purchasePrice = 3_200.0,
expiryDate = daysFromFixed(200),
residenceId = primaryHome.id,
residence = primaryHome.id,
createdById = 42,
isActive = true,
createdAt = FIXED_ISO_TIMESTAMP,
updatedAt = FIXED_ISO_TIMESTAMP,
category = "hvac",
itemName = "Gas Furnace",
provider = "Trane",
daysUntilExpiration = 200,
),
Document(
id = 2,
title = "Dishwasher Warranty (expired)",
documentType = "warranty",
description = "Bosch SHE3AR76UC — original 1-year warranty, lapsed.",
fileName = "bosch-warranty.pdf",
fileSize = 180_000,
mimeType = "application/pdf",
modelNumber = "SHE3AR76UC",
serialNumber = "BSH-44120",
vendor = "Bosch",
purchaseDate = daysFromFixed(-365 * 2),
purchasePrice = 799.0,
expiryDate = daysFromFixed(-30),
residenceId = primaryHome.id,
residence = primaryHome.id,
createdById = 42,
isActive = true,
createdAt = FIXED_ISO_TIMESTAMP,
updatedAt = FIXED_ISO_TIMESTAMP,
category = "appliance",
itemName = "Dishwasher",
provider = "Bosch",
daysUntilExpiration = -30,
),
Document(
id = 3,
title = "HVAC Manual",
documentType = "manual",
description = "Owner's manual for the furnace + condenser pair.",
fileName = "trane-manual.pdf",
fileSize = 1_450_000,
mimeType = "application/pdf",
residenceId = primaryHome.id,
residence = primaryHome.id,
createdById = 42,
isActive = true,
createdAt = FIXED_ISO_TIMESTAMP,
updatedAt = FIXED_ISO_TIMESTAMP,
category = "hvac",
itemName = "Gas Furnace",
),
Document(
id = 4,
title = "Microwave Manual",
documentType = "manual",
description = "Over-range microwave installation & user guide.",
fileName = "microwave-manual.pdf",
fileSize = 520_000,
mimeType = "application/pdf",
residenceId = primaryHome.id,
residence = primaryHome.id,
createdById = 42,
isActive = true,
createdAt = FIXED_ISO_TIMESTAMP,
updatedAt = FIXED_ISO_TIMESTAMP,
category = "appliance",
itemName = "Microwave",
),
Document(
id = 5,
title = "Well Pump Service Log",
documentType = "manual",
description = "Yearly inspection notes for the lake cabin's well pump.",
fileName = "well-pump-log.pdf",
fileSize = 95_000,
mimeType = "application/pdf",
residenceId = lakeCabin.id,
residence = lakeCabin.id,
createdById = 42,
isActive = true,
createdAt = FIXED_ISO_TIMESTAMP,
updatedAt = FIXED_ISO_TIMESTAMP,
category = "plumbing",
itemName = "Well Pump",
),
)
val documentsByResidence: Map<Int, List<Document>> = documents.groupBy { it.residenceId ?: it.residence }
// ==================== TOTAL SUMMARY ====================
val totalSummary: TotalSummary = TotalSummary(
totalResidences = residences.size,
totalTasks = tasks.size,
totalPending = tasks.count { !(it.kanbanColumn == "completed_tasks" || it.kanbanColumn == "cancelled_tasks") },
totalOverdue = tasks.count { it.kanbanColumn == "overdue_tasks" },
tasksDueNextWeek = tasks.count { it.kanbanColumn == "due_soon_tasks" },
tasksDueNextMonth = tasks.count { it.kanbanColumn == "due_soon_tasks" },
)
// ==================== SUBSCRIPTION ====================
private val tierLimits: Map<String, TierLimits> = mapOf(
"free" to TierLimits(properties = 1, tasks = 10, contractors = 3, documents = 5),
"pro" to TierLimits(properties = null, tasks = null, contractors = null, documents = null),
)
val freeSubscription: SubscriptionStatus = SubscriptionStatus(
tier = "free",
isActive = true,
usage = UsageStats(
propertiesCount = 0,
tasksCount = 0,
contractorsCount = 0,
documentsCount = 0,
),
limits = tierLimits,
limitationsEnabled = true,
)
val premiumSubscription: SubscriptionStatus = SubscriptionStatus(
tier = "pro",
isActive = true,
subscribedAt = daysFromFixed(-120),
expiresAt = daysFromFixed(245),
autoRenew = true,
usage = UsageStats(
propertiesCount = residences.size,
tasksCount = tasks.size,
contractorsCount = contractors.size,
documentsCount = documents.size,
),
limits = tierLimits,
limitationsEnabled = false,
trialActive = false,
subscriptionSource = "apple",
)
// ==================== FEATURE BENEFITS ====================
val featureBenefits: List<FeatureBenefit> = listOf(
FeatureBenefit(
featureName = "Residences",
freeTierText = "Up to 1 property",
proTierText = "Unlimited properties",
),
FeatureBenefit(
featureName = "Tasks",
freeTierText = "10 active tasks",
proTierText = "Unlimited tasks",
),
FeatureBenefit(
featureName = "Contractors",
freeTierText = "3 contractors",
proTierText = "Unlimited contractors",
),
FeatureBenefit(
featureName = "Documents",
freeTierText = "5 documents",
proTierText = "Unlimited documents",
),
)
// ==================== TASK TEMPLATES ====================
val taskTemplates: List<TaskTemplate> = listOf(
TaskTemplate(
id = 1,
title = "Replace HVAC filter",
description = "Swap out furnace/AC air filter.",
categoryId = 3,
category = taskCategories[2],
frequencyId = 3,
frequency = taskFrequencies[2],
iconIos = "air.purifier",
iconAndroid = "hvac",
tags = listOf("hvac", "seasonal"),
displayOrder = 1,
),
TaskTemplate(
id = 2,
title = "Clean gutters",
description = "Clear leaves and debris from gutters and downspouts.",
categoryId = 4,
category = taskCategories[3],
frequencyId = 3,
frequency = taskFrequencies[2],
iconIos = "house.fill",
iconAndroid = "home",
tags = listOf("exterior", "seasonal"),
displayOrder = 2,
),
)
val taskTemplatesGrouped: TaskTemplatesGroupedResponse = TaskTemplatesGroupedResponse(
categories = listOf(
TaskTemplateCategoryGroup(
categoryName = "HVAC",
categoryId = 3,
templates = taskTemplates.filter { it.categoryId == 3 },
count = taskTemplates.count { it.categoryId == 3 },
),
TaskTemplateCategoryGroup(
categoryName = "Exterior",
categoryId = 4,
templates = taskTemplates.filter { it.categoryId == 4 },
count = taskTemplates.count { it.categoryId == 4 },
),
),
totalCount = taskTemplates.size,
)
}
@@ -0,0 +1,123 @@
package com.tt.honeyDue.testing
import com.tt.honeyDue.data.IDataManager
import com.tt.honeyDue.models.ContractorSpecialty
import com.tt.honeyDue.models.ContractorSummary
import com.tt.honeyDue.models.Document
import com.tt.honeyDue.models.FeatureBenefit
import com.tt.honeyDue.models.MyResidencesResponse
import com.tt.honeyDue.models.Promotion
import com.tt.honeyDue.models.Residence
import com.tt.honeyDue.models.ResidenceSummaryResponse
import com.tt.honeyDue.models.ResidenceType
import com.tt.honeyDue.models.SubscriptionStatus
import com.tt.honeyDue.models.TaskCategory
import com.tt.honeyDue.models.TaskColumnsResponse
import com.tt.honeyDue.models.TaskFrequency
import com.tt.honeyDue.models.TaskPriority
import com.tt.honeyDue.models.TaskTemplate
import com.tt.honeyDue.models.TaskTemplatesGroupedResponse
import com.tt.honeyDue.models.TotalSummary
import com.tt.honeyDue.models.UpgradeTriggerData
import com.tt.honeyDue.models.User
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Test-only [IDataManager] backed by pre-populated [MutableStateFlow]s.
*
* Not intended for direct instantiation — use the factories on
* [FixtureDataManager] (`empty()` / `populated()`) which wire up consistent
* graphs from [Fixtures]. Because every field is constructor-injected, the
* class supports arbitrary test scenarios without touching the real
* [com.tt.honeyDue.data.DataManager] singleton.
*
* All lookup helpers resolve from the provided lists. The instance has no
* side effects: there are no API calls, no disk persistence, no clocks.
*/
class InMemoryDataManager(
currentUser: User? = null,
residences: List<Residence> = emptyList(),
myResidencesResponse: MyResidencesResponse? = null,
totalSummary: TotalSummary? = null,
residenceSummaries: Map<Int, ResidenceSummaryResponse> = emptyMap(),
allTasks: TaskColumnsResponse? = null,
tasksByResidence: Map<Int, TaskColumnsResponse> = emptyMap(),
documents: List<Document> = emptyList(),
documentsByResidence: Map<Int, List<Document>> = emptyMap(),
contractors: List<ContractorSummary> = emptyList(),
subscription: SubscriptionStatus? = null,
upgradeTriggers: Map<String, UpgradeTriggerData> = emptyMap(),
featureBenefits: List<FeatureBenefit> = emptyList(),
promotions: List<Promotion> = emptyList(),
residenceTypes: List<ResidenceType> = emptyList(),
taskFrequencies: List<TaskFrequency> = emptyList(),
taskPriorities: List<TaskPriority> = emptyList(),
taskCategories: List<TaskCategory> = emptyList(),
contractorSpecialties: List<ContractorSpecialty> = emptyList(),
taskTemplates: List<TaskTemplate> = emptyList(),
taskTemplatesGrouped: TaskTemplatesGroupedResponse? = null,
) : IDataManager {
// ==================== AUTH ====================
override val currentUser: StateFlow<User?> = MutableStateFlow(currentUser)
// ==================== RESIDENCES ====================
override val residences: StateFlow<List<Residence>> = MutableStateFlow(residences)
override val myResidences: StateFlow<MyResidencesResponse?> = MutableStateFlow(myResidencesResponse)
override val totalSummary: StateFlow<TotalSummary?> = MutableStateFlow(totalSummary)
override val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>> = MutableStateFlow(residenceSummaries)
// ==================== TASKS ====================
override val allTasks: StateFlow<TaskColumnsResponse?> = MutableStateFlow(allTasks)
override val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>> = MutableStateFlow(tasksByResidence)
// ==================== DOCUMENTS ====================
override val documents: StateFlow<List<Document>> = MutableStateFlow(documents)
override val documentsByResidence: StateFlow<Map<Int, List<Document>>> = MutableStateFlow(documentsByResidence)
// ==================== CONTRACTORS ====================
override val contractors: StateFlow<List<ContractorSummary>> = MutableStateFlow(contractors)
// ==================== SUBSCRIPTION ====================
override val subscription: StateFlow<SubscriptionStatus?> = MutableStateFlow(subscription)
override val upgradeTriggers: StateFlow<Map<String, UpgradeTriggerData>> = MutableStateFlow(upgradeTriggers)
override val featureBenefits: StateFlow<List<FeatureBenefit>> = MutableStateFlow(featureBenefits)
override val promotions: StateFlow<List<Promotion>> = MutableStateFlow(promotions)
// ==================== LOOKUPS ====================
override val residenceTypes: StateFlow<List<ResidenceType>> = MutableStateFlow(residenceTypes)
override val taskFrequencies: StateFlow<List<TaskFrequency>> = MutableStateFlow(taskFrequencies)
override val taskPriorities: StateFlow<List<TaskPriority>> = MutableStateFlow(taskPriorities)
override val taskCategories: StateFlow<List<TaskCategory>> = MutableStateFlow(taskCategories)
override val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = MutableStateFlow(contractorSpecialties)
// ==================== TASK TEMPLATES ====================
override val taskTemplates: StateFlow<List<TaskTemplate>> = MutableStateFlow(taskTemplates)
override val taskTemplatesGrouped: StateFlow<TaskTemplatesGroupedResponse?> = MutableStateFlow(taskTemplatesGrouped)
// ==================== LOOKUP HELPERS ====================
override fun getResidenceType(id: Int?): ResidenceType? =
id?.let { wanted -> residenceTypes.value.firstOrNull { it.id == wanted } }
override fun getTaskFrequency(id: Int?): TaskFrequency? =
id?.let { wanted -> taskFrequencies.value.firstOrNull { it.id == wanted } }
override fun getTaskPriority(id: Int?): TaskPriority? =
id?.let { wanted -> taskPriorities.value.firstOrNull { it.id == wanted } }
override fun getTaskCategory(id: Int?): TaskCategory? =
id?.let { wanted -> taskCategories.value.firstOrNull { it.id == wanted } }
override fun getContractorSpecialty(id: Int?): ContractorSpecialty? =
id?.let { wanted -> contractorSpecialties.value.firstOrNull { it.id == wanted } }
}