diff --git a/Android/app/src/main/java/live/ditto/pos/DittoPOSApplication.kt b/Android/app/src/main/java/live/ditto/pos/DittoPOSApplication.kt index 744707c..634e989 100644 --- a/Android/app/src/main/java/live/ditto/pos/DittoPOSApplication.kt +++ b/Android/app/src/main/java/live/ditto/pos/DittoPOSApplication.kt @@ -6,22 +6,37 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import live.ditto.pos.core.domain.repository.DittoRepository +import live.ditto.ditto_wrapper.DittoManager +import live.ditto.pos.core.data.demo.DemoSeeder +import live.ditto.pos.core.data.repository.LocationsRepository +import live.ditto.pos.core.data.repository.OrdersRepository +import live.ditto.pos.core.data.repository.SaleItemsRepository import javax.inject.Inject @HiltAndroidApp class DittoPOSApplication : Application() { - @Inject lateinit var dittoRepository: DittoRepository + @Inject lateinit var dittoManager: DittoManager + + @Inject lateinit var locationsRepository: LocationsRepository + + @Inject lateinit var ordersRepository: OrdersRepository + + // Injected here even though we don't reference it directly — its init + // block sets up the location-id flow collector that drives the sale_items + // subscription on app start. + @Suppress("unused") + @Inject + lateinit var saleItemsRepository: SaleItemsRepository private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) override fun onCreate() { super.onCreate() - dittoRepository.startLocationsSubscription() + locationsRepository.startSubscription() applicationScope.launch { - dittoRepository.seedAll() - dittoRepository.runEvictionIfDue() + DemoSeeder(dittoManager.requireDitto()).seedAll() + ordersRepository.runEvictionIfDue() } } } diff --git a/Android/app/src/main/java/live/ditto/pos/MainActivity.kt b/Android/app/src/main/java/live/ditto/pos/MainActivity.kt index fd90c87..a3de896 100644 --- a/Android/app/src/main/java/live/ditto/pos/MainActivity.kt +++ b/Android/app/src/main/java/live/ditto/pos/MainActivity.kt @@ -9,7 +9,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import dagger.hilt.android.AndroidEntryPoint import live.ditto.pos.core.presentation.composables.screens.PosKdsApp -import live.ditto.pos.core.presentation.viewmodel.CoreViewModel +import live.ditto.pos.core.presentation.viewmodel.MainViewModel val LocalActivity = staticCompositionLocalOf { error("LocalActivity is not present") @@ -18,7 +18,7 @@ val LocalActivity = staticCompositionLocalOf { @AndroidEntryPoint class MainActivity : ComponentActivity() { - private val viewModel: CoreViewModel by viewModels() + private val viewModel: MainViewModel by viewModels() private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { viewModel.refreshDittoPermissions() diff --git a/Android/app/src/main/java/live/ditto/pos/core/data/repository/LocationsRepository.kt b/Android/app/src/main/java/live/ditto/pos/core/data/repository/LocationsRepository.kt new file mode 100644 index 0000000..28f17c6 --- /dev/null +++ b/Android/app/src/main/java/live/ditto/pos/core/data/repository/LocationsRepository.kt @@ -0,0 +1,73 @@ +package live.ditto.pos.core.data.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import live.ditto.Ditto +import live.ditto.DittoSyncSubscription +import live.ditto.ditto_wrapper.DittoManager +import live.ditto.pos.core.data.demo.LocationSeed +import live.ditto.pos.core.data.locations.Location +import live.ditto.pos.core.data.observeAsFlow +import live.ditto.pos.settings.AppSettings +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Owns the `locations` collection and the active-location state. + * `setActiveLocation` is the only writer — it persists to [AppSettings] and + * applies [DittoManager]'s routing config. Other per-collection repositories + * subscribe to `locationIdFlow()` and re-register their own subscriptions + * when it emits. Mirrors iOS `LocationsRepository`. + */ +@Singleton +class LocationsRepository @Inject constructor( + private val dittoManager: DittoManager, + private val appSettings: AppSettings +) { + private val ditto: Ditto get() = dittoManager.requireDitto() + private var subscription: DittoSyncSubscription? = null + private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + init { + // Restore the persisted active location on first injection (mirrors + // iOS, where this happens synchronously in init). Routing config + // applies as part of `setActiveLocation`. + repoScope.launch { + val saved = appSettings.locationId() + if (saved.isNotEmpty()) { + setActiveLocation(saved) + } + } + } + + fun startSubscription() { + if (subscription != null) return + subscription = ditto.sync.registerSubscription( + "SELECT * FROM ${Location.COLLECTION_NAME}" + ) + } + + /** + * Switch the active location. Persists, applies routing, and downstream + * repositories react via `locationIdFlow()`. Pass an empty string to + * clear the selection. + */ + suspend fun setActiveLocation(locationId: String) { + appSettings.setLocationId(locationId = locationId) + if (locationId.isNotEmpty()) { + dittoManager.setRoutingConfig(locationId) + } + } + + /** Reactive stream of the active location id. Empty string when not set. */ + fun locationIdFlow(): Flow = appSettings.locationIdFlow() + + fun observeAllLocations(): Flow> = + ditto.store + .observeAsFlow("SELECT * FROM ${Location.COLLECTION_NAME}") + .map { locations -> locations.filter { it.id in LocationSeed.demoLocationIds } } +} diff --git a/Android/app/src/main/java/live/ditto/pos/core/domain/repository/DittoRepository.kt b/Android/app/src/main/java/live/ditto/pos/core/data/repository/OrdersRepository.kt similarity index 57% rename from Android/app/src/main/java/live/ditto/pos/core/domain/repository/DittoRepository.kt rename to Android/app/src/main/java/live/ditto/pos/core/data/repository/OrdersRepository.kt index 4d419b7..1cd36f1 100644 --- a/Android/app/src/main/java/live/ditto/pos/core/domain/repository/DittoRepository.kt +++ b/Android/app/src/main/java/live/ditto/pos/core/data/repository/OrdersRepository.kt @@ -1,11 +1,13 @@ -package live.ditto.pos.core.domain.repository +package live.ditto.pos.core.data.repository import android.content.Context +import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -16,111 +18,56 @@ import kotlinx.datetime.toLocalDateTime import live.ditto.Ditto import live.ditto.DittoSyncSubscription import live.ditto.ditto_wrapper.DittoManager -import live.ditto.pos.core.data.SaleItem -import live.ditto.pos.core.data.demo.DemoSeeder import live.ditto.pos.core.data.dittoJsonString -import live.ditto.pos.core.data.locations.Location import live.ditto.pos.core.data.observeAsFlow import live.ditto.pos.core.data.orders.Order import live.ditto.pos.core.data.toDittoIsoString -import live.ditto.pos.settings.AppSettings import javax.inject.Inject import javax.inject.Singleton @Singleton -class DittoRepository -@Inject -constructor( +class OrdersRepository @Inject constructor( @ApplicationContext private val context: Context, private val dittoManager: DittoManager, - private val appSettings: AppSettings + private val locationsRepository: LocationsRepository ) { private val ditto: Ditto get() = dittoManager.requireDitto() - private val activeSubs = mutableMapOf() + private var subscription: DittoSyncSubscription? = null private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) init { - // Track the saved locationId and (re-)configure routing + subscriptions - // whenever it's set. Covers both the user picking a location and app - // re-launch with a value restored from DataStore. - appSettings.locationIdFlow() + // Pull the active location from LocationsRepository and re-register + // the subscription on every change. Mirrors iOS. + locationsRepository.locationIdFlow() .filter { it.isNotEmpty() } - .onEach { setActiveLocation(it) } + .distinctUntilChanged() + .onEach { locationId -> setActiveLocation(locationId) } .launchIn(repoScope) } - fun requireDitto(): Ditto = ditto - - fun refreshPermissions() { - ditto.refreshPermissions() - } - - fun getMissingPermissions(): Array = dittoManager.missingPermissions() - - // ----- Subscriptions ----- - - fun startLocationsSubscription() { - registerSub( - key = "locations", - query = "SELECT * FROM ${Location.COLLECTION_NAME}" - ) - } - - fun setActiveLocation(locationId: String) { - dittoManager.setRoutingConfig(locationId) - registerSub( - key = "orders", - query = """ - SELECT * FROM ${Order.COLLECTION_NAME} - WHERE _id.locationId = :locationId - AND createdAt > :TTL - """.trimIndent(), - args = mapOf("locationId" to locationId, "TTL" to startOfTodayIso()) - ) - registerSub( - key = "sale_items", - query = """ - SELECT * FROM ${SaleItem.COLLECTION_NAME} - WHERE _id.locationId = :locationId - ORDER BY name + private fun setActiveLocation(locationId: String) { + subscription?.close() + subscription = ditto.sync.registerSubscription( + """ + SELECT * FROM ${Order.COLLECTION_NAME} + WHERE _id.locationId = :locationId + AND createdAt > :TTL """.trimIndent(), - args = mapOf("locationId" to locationId) + mapOf("locationId" to locationId, "TTL" to startOfTodayIso()) ) } - private fun registerSub(key: String, query: String, args: Map = emptyMap()) { - activeSubs[key]?.close() - activeSubs[key] = ditto.sync.registerSubscription(query, args) - } - - // ----- Observers ----- - - fun observeAllLocations(): Flow> = - ditto.store.observeAsFlow("SELECT * FROM ${Location.COLLECTION_NAME}") - fun observeLocationOrders(locationId: String): Flow> = ditto.store.observeAsFlow( query = """ - SELECT * FROM ${Order.COLLECTION_NAME} - WHERE _id.locationId = :locationId - AND createdAt > :TTL + SELECT * FROM ${Order.COLLECTION_NAME} + WHERE _id.locationId = :locationId + AND createdAt > :TTL """.trimIndent(), args = mapOf("locationId" to locationId, "TTL" to startOfTodayIso()) ) - fun observeLocationSaleItems(locationId: String): Flow> = - ditto.store.observeAsFlow( - query = """ - SELECT * FROM ${SaleItem.COLLECTION_NAME} - WHERE _id.locationId = :locationId - ORDER BY name - """.trimIndent(), - args = mapOf("locationId" to locationId) - ) - - // ----- Mutations ----- - - suspend fun upsertOrder(order: Order) { + suspend fun upsert(order: Order) { ditto.store.execute( """ INSERT INTO ${Order.COLLECTION_NAME} @@ -144,7 +91,7 @@ constructor( ).use { } } - suspend fun resetOrder(order: Order) { + suspend fun reset(order: Order) { val createdAtNow = Clock.System.now().toDittoIsoString() val baseArgs = mapOf( "id" to order.documentId.id, @@ -169,12 +116,6 @@ constructor( ditto.store.execute(query, baseArgs).use { } } - // ----- Lifecycle ----- - - suspend fun seedAll() { - DemoSeeder(ditto).seedAll() - } - suspend fun runEvictionIfDue() { val prefs = context.getSharedPreferences(EVICTION_PREFS, Context.MODE_PRIVATE) val now = Clock.System.now().toEpochMilliseconds() @@ -188,9 +129,9 @@ constructor( mapOf("TTL" to ttl) ).use { } prefs.edit().putLong(LAST_EVICTION_KEY, now).apply() - android.util.Log.i("Eviction", "evicted orders with createdAt <= $ttl") + Log.i("Eviction", "evicted orders with createdAt <= $ttl") } catch (error: Throwable) { - android.util.Log.w("Eviction", error.message.orEmpty()) + Log.w("Eviction", error.message.orEmpty()) } } diff --git a/Android/app/src/main/java/live/ditto/pos/core/data/repository/SaleItemsRepository.kt b/Android/app/src/main/java/live/ditto/pos/core/data/repository/SaleItemsRepository.kt new file mode 100644 index 0000000..cc2f375 --- /dev/null +++ b/Android/app/src/main/java/live/ditto/pos/core/data/repository/SaleItemsRepository.kt @@ -0,0 +1,57 @@ +package live.ditto.pos.core.data.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import live.ditto.Ditto +import live.ditto.DittoSyncSubscription +import live.ditto.ditto_wrapper.DittoManager +import live.ditto.pos.core.data.SaleItem +import live.ditto.pos.core.data.observeAsFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SaleItemsRepository @Inject constructor( + private val dittoManager: DittoManager, + private val locationsRepository: LocationsRepository +) { + private val ditto: Ditto get() = dittoManager.requireDitto() + private var subscription: DittoSyncSubscription? = null + private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + init { + locationsRepository.locationIdFlow() + .filter { it.isNotEmpty() } + .distinctUntilChanged() + .onEach { locationId -> setActiveLocation(locationId) } + .launchIn(repoScope) + } + + private fun setActiveLocation(locationId: String) { + subscription?.close() + subscription = ditto.sync.registerSubscription( + """ + SELECT * FROM ${SaleItem.COLLECTION_NAME} + WHERE _id.locationId = :locationId + ORDER BY name + """.trimIndent(), + mapOf("locationId" to locationId) + ) + } + + fun observeLocationSaleItems(locationId: String): Flow> = + ditto.store.observeAsFlow( + query = """ + SELECT * FROM ${SaleItem.COLLECTION_NAME} + WHERE _id.locationId = :locationId + ORDER BY name + """.trimIndent(), + args = mapOf("locationId" to locationId) + ) +} diff --git a/Android/app/src/main/java/live/ditto/pos/core/domain/usecase/SetCurrentLocationUseCase.kt b/Android/app/src/main/java/live/ditto/pos/core/domain/usecase/SetCurrentLocationUseCase.kt deleted file mode 100644 index debafd5..0000000 --- a/Android/app/src/main/java/live/ditto/pos/core/domain/usecase/SetCurrentLocationUseCase.kt +++ /dev/null @@ -1,16 +0,0 @@ -package live.ditto.pos.core.domain.usecase - -import live.ditto.pos.settings.AppSettings -import javax.inject.Inject - -class SetCurrentLocationUseCase @Inject constructor( - private val appSettings: AppSettings -) { - - // Persist the chosen locationId; DittoRepository observes the same flow - // and (re-)configures routing + the orders/sale_items subscriptions for - // the new value. - suspend operator fun invoke(locationId: String) { - appSettings.setLocationId(locationId = locationId) - } -} diff --git a/Android/app/src/main/java/live/ditto/pos/core/domain/usecase/ditto/GetDittoInstanceUseCase.kt b/Android/app/src/main/java/live/ditto/pos/core/domain/usecase/ditto/GetDittoInstanceUseCase.kt index 64ec2b1..73c24fa 100644 --- a/Android/app/src/main/java/live/ditto/pos/core/domain/usecase/ditto/GetDittoInstanceUseCase.kt +++ b/Android/app/src/main/java/live/ditto/pos/core/domain/usecase/ditto/GetDittoInstanceUseCase.kt @@ -1,10 +1,10 @@ package live.ditto.pos.core.domain.usecase.ditto import live.ditto.Ditto -import live.ditto.pos.core.domain.repository.DittoRepository +import live.ditto.ditto_wrapper.DittoManager import javax.inject.Inject -class GetDittoInstanceUseCase @Inject constructor(private val dittoRepository: DittoRepository) { +class GetDittoInstanceUseCase @Inject constructor(private val dittoManager: DittoManager) { - operator fun invoke(): Ditto = dittoRepository.requireDitto() + operator fun invoke(): Ditto = dittoManager.requireDitto() } diff --git a/Android/app/src/main/java/live/ditto/pos/core/domain/usecase/ditto/GetMissingPermissionsUseCase.kt b/Android/app/src/main/java/live/ditto/pos/core/domain/usecase/ditto/GetMissingPermissionsUseCase.kt index 0a30d63..1afc7f1 100644 --- a/Android/app/src/main/java/live/ditto/pos/core/domain/usecase/ditto/GetMissingPermissionsUseCase.kt +++ b/Android/app/src/main/java/live/ditto/pos/core/domain/usecase/ditto/GetMissingPermissionsUseCase.kt @@ -1,13 +1,13 @@ package live.ditto.pos.core.domain.usecase.ditto -import live.ditto.pos.core.domain.repository.DittoRepository +import live.ditto.ditto_wrapper.DittoManager import javax.inject.Inject class GetMissingPermissionsUseCase @Inject constructor( - private val dittoRepository: DittoRepository + private val dittoManager: DittoManager ) { operator fun invoke(): Array { - return dittoRepository.getMissingPermissions() + return dittoManager.missingPermissions() } } diff --git a/Android/app/src/main/java/live/ditto/pos/core/domain/usecase/ditto/RefreshDittoPermissionsUseCase.kt b/Android/app/src/main/java/live/ditto/pos/core/domain/usecase/ditto/RefreshDittoPermissionsUseCase.kt index 86c8794..2afda7d 100644 --- a/Android/app/src/main/java/live/ditto/pos/core/domain/usecase/ditto/RefreshDittoPermissionsUseCase.kt +++ b/Android/app/src/main/java/live/ditto/pos/core/domain/usecase/ditto/RefreshDittoPermissionsUseCase.kt @@ -1,11 +1,11 @@ package live.ditto.pos.core.domain.usecase.ditto -import live.ditto.pos.core.domain.repository.DittoRepository +import live.ditto.ditto_wrapper.DittoManager import javax.inject.Inject -class RefreshDittoPermissionsUseCase @Inject constructor(private val dittoRepository: DittoRepository) { +class RefreshDittoPermissionsUseCase @Inject constructor(private val dittoManager: DittoManager) { operator fun invoke() { - dittoRepository.refreshPermissions() + dittoManager.refreshPermissions() } } diff --git a/Android/app/src/main/java/live/ditto/pos/core/presentation/composables/screens/DemoLocationSelectionScreen.kt b/Android/app/src/main/java/live/ditto/pos/core/presentation/composables/screens/DemoLocationSelectionScreen.kt index 5018b6e..990fc5d 100644 --- a/Android/app/src/main/java/live/ditto/pos/core/presentation/composables/screens/DemoLocationSelectionScreen.kt +++ b/Android/app/src/main/java/live/ditto/pos/core/presentation/composables/screens/DemoLocationSelectionScreen.kt @@ -7,17 +7,17 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import live.ditto.pos.LocalActivity import live.ditto.pos.core.presentation.composables.DemoLocationsList -import live.ditto.pos.core.presentation.viewmodel.CoreViewModel +import live.ditto.pos.core.presentation.viewmodel.MainViewModel @Composable fun DemoLocationSelectionScreen( - coreViewModel: CoreViewModel = hiltViewModel(LocalActivity.current), + mainViewModel: MainViewModel = hiltViewModel(LocalActivity.current), navHostController: NavHostController ) { DemoLocationsList( modifier = Modifier.fillMaxSize(), onDemoLocationSelected = { - coreViewModel.updateCurrentLocation(locationId = it.id) + mainViewModel.updateCurrentLocation(locationId = it.id) navHostController.popBackStack() } ) diff --git a/Android/app/src/main/java/live/ditto/pos/core/presentation/composables/screens/InitialSetupScreen.kt b/Android/app/src/main/java/live/ditto/pos/core/presentation/composables/screens/InitialSetupScreen.kt index d510c60..7b2036b 100644 --- a/Android/app/src/main/java/live/ditto/pos/core/presentation/composables/screens/InitialSetupScreen.kt +++ b/Android/app/src/main/java/live/ditto/pos/core/presentation/composables/screens/InitialSetupScreen.kt @@ -4,16 +4,16 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.window.Dialog import androidx.hilt.navigation.compose.hiltViewModel import live.ditto.pos.core.presentation.composables.DemoLocationsList -import live.ditto.pos.core.presentation.viewmodel.CoreViewModel +import live.ditto.pos.core.presentation.viewmodel.MainViewModel @Composable fun InitialSetupScreen( - coreViewModel: CoreViewModel = hiltViewModel() + mainViewModel: MainViewModel = hiltViewModel() ) { Dialog(onDismissRequest = { }) { DemoLocationsList( onDemoLocationSelected = { - coreViewModel.updateCurrentLocation(locationId = it.id) + mainViewModel.updateCurrentLocation(locationId = it.id) } ) } diff --git a/Android/app/src/main/java/live/ditto/pos/core/presentation/composables/screens/PosKdsApp.kt b/Android/app/src/main/java/live/ditto/pos/core/presentation/composables/screens/PosKdsApp.kt index 1e973cb..70e70a8 100644 --- a/Android/app/src/main/java/live/ditto/pos/core/presentation/composables/screens/PosKdsApp.kt +++ b/Android/app/src/main/java/live/ditto/pos/core/presentation/composables/screens/PosKdsApp.kt @@ -35,12 +35,12 @@ import live.ditto.pos.core.presentation.composables.navigation.PosKdsNavigationB import live.ditto.pos.core.presentation.navigation.NavigationDrawerItem import live.ditto.pos.core.presentation.navigation.PosKdsNavHost import live.ditto.pos.core.presentation.viewmodel.AppState -import live.ditto.pos.core.presentation.viewmodel.CoreViewModel +import live.ditto.pos.core.presentation.viewmodel.MainViewModel import live.ditto.pos.ui.theme.DittoPoSKDSDemoTheme @Composable fun PosKdsApp( - viewModel: CoreViewModel = hiltViewModel(LocalActivity.current) + viewModel: MainViewModel = hiltViewModel(LocalActivity.current) ) { val state by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/Android/app/src/main/java/live/ditto/pos/core/presentation/navigation/PodKdsNavHost.kt b/Android/app/src/main/java/live/ditto/pos/core/presentation/navigation/PodKdsNavHost.kt index a14eced..9d9d473 100644 --- a/Android/app/src/main/java/live/ditto/pos/core/presentation/navigation/PodKdsNavHost.kt +++ b/Android/app/src/main/java/live/ditto/pos/core/presentation/navigation/PodKdsNavHost.kt @@ -7,7 +7,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import live.ditto.pos.core.presentation.composables.screens.AdvancedSettingsScreen import live.ditto.pos.core.presentation.composables.screens.DemoLocationSelectionScreen -import live.ditto.pos.core.presentation.viewmodel.CoreViewModel +import live.ditto.pos.core.presentation.viewmodel.MainViewModel import live.ditto.pos.kds.presentation.composables.KdsScreen import live.ditto.pos.pos.presentation.composables.screens.PosScreen import live.ditto.tools.toolsviewer.DittoToolsViewer @@ -15,7 +15,7 @@ import live.ditto.tools.toolsviewer.DittoToolsViewer @Composable fun PosKdsNavHost( navHostController: NavHostController, - viewModel: CoreViewModel = hiltViewModel(), + viewModel: MainViewModel = hiltViewModel(), onSettingsUpdated: () -> Unit ) { NavHost( diff --git a/Android/app/src/main/java/live/ditto/pos/core/presentation/viewmodel/CoreViewModel.kt b/Android/app/src/main/java/live/ditto/pos/core/presentation/viewmodel/MainViewModel.kt similarity index 82% rename from Android/app/src/main/java/live/ditto/pos/core/presentation/viewmodel/CoreViewModel.kt rename to Android/app/src/main/java/live/ditto/pos/core/presentation/viewmodel/MainViewModel.kt index 466306f..f0635b9 100644 --- a/Android/app/src/main/java/live/ditto/pos/core/presentation/viewmodel/CoreViewModel.kt +++ b/Android/app/src/main/java/live/ditto/pos/core/presentation/viewmodel/MainViewModel.kt @@ -9,23 +9,23 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import live.ditto.Ditto +import live.ditto.pos.core.data.repository.LocationsRepository import live.ditto.pos.core.domain.usecase.AppConfigurationStateUseCase import live.ditto.pos.core.domain.usecase.AppConfigurationStateUseCase.AppConfigurationState import live.ditto.pos.core.domain.usecase.GetCurrentLocationUseCase -import live.ditto.pos.core.domain.usecase.SetCurrentLocationUseCase import live.ditto.pos.core.domain.usecase.ditto.GetDittoInstanceUseCase import live.ditto.pos.core.domain.usecase.ditto.GetMissingPermissionsUseCase import live.ditto.pos.core.domain.usecase.ditto.RefreshDittoPermissionsUseCase import javax.inject.Inject @HiltViewModel -class CoreViewModel @Inject constructor( +class MainViewModel @Inject constructor( private val refreshDittoPermissionsUseCase: RefreshDittoPermissionsUseCase, private val getDittoInstanceUseCase: GetDittoInstanceUseCase, private val appConfigurationStateUseCase: AppConfigurationStateUseCase, private val getCurrentLocationUseCase: GetCurrentLocationUseCase, private val getMissingPermissionsUseCase: GetMissingPermissionsUseCase, - private val setCurrentLocationUseCase: SetCurrentLocationUseCase + private val locationsRepository: LocationsRepository ) : ViewModel() { private val _uiState = MutableStateFlow( @@ -38,14 +38,6 @@ class CoreViewModel @Inject constructor( init { viewModelScope.launch(Dispatchers.IO) { - // If a location is already stored, restore routing config and subscriptions - val state = appConfigurationStateUseCase() - if (state == AppConfigurationState.VALID) { - val location = getCurrentLocationUseCase() - if (location != null) { - setCurrentLocationUseCase(locationId = location.id) - } - } updateAppState() } } @@ -64,7 +56,7 @@ class CoreViewModel @Inject constructor( fun updateCurrentLocation(locationId: String) { viewModelScope.launch(Dispatchers.IO) { - setCurrentLocationUseCase(locationId = locationId) + locationsRepository.setActiveLocation(locationId = locationId) updateAppState() } } diff --git a/Android/app/src/main/java/live/ditto/pos/kds/KDSViewModel.kt b/Android/app/src/main/java/live/ditto/pos/kds/KDSViewModel.kt index 87ef367..8959bba 100644 --- a/Android/app/src/main/java/live/ditto/pos/kds/KDSViewModel.kt +++ b/Android/app/src/main/java/live/ditto/pos/kds/KDSViewModel.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.launch import kotlinx.datetime.Instant import live.ditto.pos.core.data.OrderStatus import live.ditto.pos.core.data.orders.Order -import live.ditto.pos.core.domain.repository.DittoRepository +import live.ditto.pos.core.data.repository.OrdersRepository import live.ditto.pos.settings.AppSettings import java.text.SimpleDateFormat import java.util.Date @@ -31,7 +31,7 @@ class KDSViewModel @Inject constructor( private val appSettings: AppSettings, - private val dittoRepository: DittoRepository, + private val ordersRepository: OrdersRepository, private val dispatcherIo: CoroutineDispatcher ) : ViewModel() { @@ -52,7 +52,7 @@ constructor( appSettings.locationIdFlow() .filter { it.isNotEmpty() } .flatMapLatest { locationId -> - dittoRepository.observeLocationOrders(locationId).map { orders -> + ordersRepository.observeLocationOrders(locationId).map { orders -> orders.filter { order -> (order.status == OrderStatus.IN_PROCESS || order.status == OrderStatus.PROCESSED) && order.cart.isNotEmpty() @@ -75,7 +75,7 @@ constructor( OrderStatus.PROCESSED -> OrderStatus.DELIVERED else -> return@launch } - dittoRepository.upsertOrder(order.appendingStatus(nextStatus)) + ordersRepository.upsert(order.appendingStatus(nextStatus)) if (nextStatus == OrderStatus.PROCESSED && appSettings.currentOrderId() == order.documentId.id) { appSettings.setCurrentOrderId("") diff --git a/Android/app/src/main/java/live/ditto/pos/pos/PoSViewModel.kt b/Android/app/src/main/java/live/ditto/pos/pos/POSViewModel.kt similarity index 87% rename from Android/app/src/main/java/live/ditto/pos/pos/PoSViewModel.kt rename to Android/app/src/main/java/live/ditto/pos/pos/POSViewModel.kt index 47c13f1..0ee7e30 100644 --- a/Android/app/src/main/java/live/ditto/pos/pos/PoSViewModel.kt +++ b/Android/app/src/main/java/live/ditto/pos/pos/POSViewModel.kt @@ -23,7 +23,8 @@ import live.ditto.pos.core.data.Payment import live.ditto.pos.core.data.PaymentType import live.ditto.pos.core.data.SaleItem import live.ditto.pos.core.data.orders.Order -import live.ditto.pos.core.domain.repository.DittoRepository +import live.ditto.pos.core.data.repository.OrdersRepository +import live.ditto.pos.core.data.repository.SaleItemsRepository import live.ditto.pos.pos.presentation.uimodel.OrderItemUiModel import live.ditto.pos.pos.presentation.uimodel.SaleItemUiModel import live.ditto.pos.settings.AppSettings @@ -31,11 +32,12 @@ import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel -class PoSViewModel +class POSViewModel @Inject constructor( private val appSettings: AppSettings, - private val dittoRepository: DittoRepository, + private val ordersRepository: OrdersRepository, + private val saleItemsRepository: SaleItemsRepository, private val dispatcherIO: CoroutineDispatcher ) : ViewModel() { @@ -66,7 +68,7 @@ constructor( .filter { it.isNotEmpty() } .flatMapLatest { locationId -> ensureCurrentOrder(locationId) - dittoRepository.observeLocationOrders(locationId).mapNotNull { orders -> + ordersRepository.observeLocationOrders(locationId).mapNotNull { orders -> orders.firstOrNull { it.documentId.id == currentOrderId } } } @@ -76,7 +78,7 @@ constructor( appSettings.locationIdFlow() .filter { it.isNotEmpty() } - .flatMapLatest { dittoRepository.observeLocationSaleItems(it) } + .flatMapLatest { saleItemsRepository.observeLocationSaleItems(it) } .onEach { items -> latestSaleItems = items _uiState.value = _uiState.value.copy( @@ -95,7 +97,7 @@ constructor( CartLineItem.from(match), lineItemId = CartLineItem.newLineItemId() ) - dittoRepository.upsertOrder(updated) + ordersRepository.upsert(updated) } } @@ -103,20 +105,20 @@ constructor( val current = latestOrder ?: return viewModelScope.launch(dispatcherIO) { val payment = Payment(type = PaymentType.CASH, amount = current.total) - dittoRepository.upsertOrder(current.addingPayment(payment, paymentId = Payment.newPaymentId())) + ordersRepository.upsert(current.addingPayment(payment, paymentId = Payment.newPaymentId())) // Open the next order explicitly; the observer never creates. val next = Order.new(locationId = current.documentId.locationId) currentOrderId = next.documentId.id appSettings.setCurrentOrderId(currentOrderId) - dittoRepository.upsertOrder(next) + ordersRepository.upsert(next) } } fun clearItems() { val current = latestOrder ?: return viewModelScope.launch(dispatcherIO) { - dittoRepository.clearCart(current) + ordersRepository.clearCart(current) } } @@ -124,7 +126,7 @@ constructor( private suspend fun ensureCurrentOrder(locationId: String) { val savedId = appSettings.currentOrderId() if (savedId.isNotEmpty()) { - val snapshot = dittoRepository.observeLocationOrders(locationId).first() + val snapshot = ordersRepository.observeLocationOrders(locationId).first() val valid = snapshot.firstOrNull { it.documentId.id == savedId }?.let { it.status == OrderStatus.OPEN || it.status == OrderStatus.IN_PROCESS } == true @@ -136,7 +138,7 @@ constructor( val order = Order.new(locationId = locationId) currentOrderId = order.documentId.id appSettings.setCurrentOrderId(currentOrderId) - dittoRepository.upsertOrder(order) + ordersRepository.upsert(order) } private fun renderOrder(order: Order) { diff --git a/Android/app/src/main/java/live/ditto/pos/pos/presentation/composables/screens/PosScreen.kt b/Android/app/src/main/java/live/ditto/pos/pos/presentation/composables/screens/PosScreen.kt index 07f6595..12e27ce 100644 --- a/Android/app/src/main/java/live/ditto/pos/pos/presentation/composables/screens/PosScreen.kt +++ b/Android/app/src/main/java/live/ditto/pos/pos/presentation/composables/screens/PosScreen.kt @@ -11,13 +11,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import live.ditto.pos.pos.PoSViewModel +import live.ditto.pos.pos.POSViewModel import live.ditto.pos.pos.presentation.composables.CurrentOrder import live.ditto.pos.pos.presentation.composables.saleitemgrid.SaleItemsGrid @Composable fun PosScreen( - viewModel: PoSViewModel = hiltViewModel() + viewModel: POSViewModel = hiltViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/Android/app/src/main/java/live/ditto/pos/settings/AppSettings.kt b/Android/app/src/main/java/live/ditto/pos/settings/AppSettings.kt index b502fe8..858d141 100644 --- a/Android/app/src/main/java/live/ditto/pos/settings/AppSettings.kt +++ b/Android/app/src/main/java/live/ditto/pos/settings/AppSettings.kt @@ -18,9 +18,10 @@ private const val DATA_STORE_NAME = "settings" /** * Device-local app preferences backed by Android DataStore. Not synced via - * Ditto — that's `DittoRepository`'s job. The two are kept in separate - * packages so the boundary between "local-only" and "synced" is visible at - * the import line. Mirrors iOS `Settings` (`iOS/DittoPOS/Settings/`). + * Ditto — the per-collection repositories in `core.data.repository` own + * synced data. The two are kept in separate packages so the boundary + * between "local-only" and "synced" is visible at the import line. Mirrors + * iOS `Settings` (`iOS/DittoPOS/Settings/`). */ @Singleton class AppSettings @Inject constructor( diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index bb4af32..413ced8 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - Ditto PoS KDS Demo + Ditto POS KDS Demo POS KDS diff --git a/Android/ditto-wrapper/src/main/java/live/ditto/ditto_wrapper/DittoManager.kt b/Android/ditto-wrapper/src/main/java/live/ditto/ditto_wrapper/DittoManager.kt index e21c852..3304efb 100644 --- a/Android/ditto-wrapper/src/main/java/live/ditto/ditto_wrapper/DittoManager.kt +++ b/Android/ditto-wrapper/src/main/java/live/ditto/ditto_wrapper/DittoManager.kt @@ -79,6 +79,10 @@ class DittoManager( fun missingPermissions(): Array { return DittoSyncPermissions(context = context).missingPermissions() } + + fun refreshPermissions() { + requireDitto().refreshPermissions() + } } class DittoNotCreatedException : Throwable("Ditto cannot be null") \ No newline at end of file diff --git a/iOS/DittoPOS.xcodeproj/project.pbxproj b/iOS/DittoPOS.xcodeproj/project.pbxproj index c7cc347..92b656b 100644 --- a/iOS/DittoPOS.xcodeproj/project.pbxproj +++ b/iOS/DittoPOS.xcodeproj/project.pbxproj @@ -18,20 +18,25 @@ 140421CF2B9AB6B800778C1B /* AdvancedSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 140421CE2B9AB6B700778C1B /* AdvancedSettings.swift */; }; 1414DEC22AA0F8D600D9CE87 /* KDSOrderItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1414DEC12AA0F8D600D9CE87 /* KDSOrderItemView.swift */; }; 1460E33B2A93DA5D007A2A6D /* KDSOrderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1460E33A2A93DA5D007A2A6D /* KDSOrderView.swift */; }; - 1460E33D2A93DCC8007A2A6D /* KDS_VM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1460E33C2A93DCC8007A2A6D /* KDS_VM.swift */; }; + 1460E33D2A93DCC8007A2A6D /* KDSViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1460E33C2A93DCC8007A2A6D /* KDSViewModel.swift */; }; 148071162A8C2E930091EC03 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 148071152A8C2E930091EC03 /* SettingsView.swift */; }; 149CABC62A32C43700729331 /* Env.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149CABC52A32C43700729331 /* Env.swift */; }; 14CD51B12A2FE2D50018DE87 /* DittoPOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14CD51B02A2FE2D50018DE87 /* DittoPOS.swift */; }; 14CD51B32A2FE2D50018DE87 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14CD51B22A2FE2D50018DE87 /* MainView.swift */; }; + DAFACE01000000000000B005 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFACE01000000000000A006 /* MainViewModel.swift */; }; 14CD51B52A2FE2D60018DE87 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 14CD51B42A2FE2D60018DE87 /* Assets.xcassets */; }; 14CD51B82A2FE2D60018DE87 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 14CD51B72A2FE2D60018DE87 /* Preview Assets.xcassets */; }; - 14CD51BF2A2FE3EE0018DE87 /* DittoService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14CD51BE2A2FE3EE0018DE87 /* DittoService.swift */; }; + 14CD51BF2A2FE3EE0018DE87 /* DittoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14CD51BE2A2FE3EE0018DE87 /* DittoManager.swift */; }; + DAFACE01000000000000B001 /* OrdersRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFACE01000000000000A001 /* OrdersRepository.swift */; }; + DAFACE01000000000000B002 /* SaleItemsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFACE01000000000000A002 /* SaleItemsRepository.swift */; }; + DAFACE01000000000000B003 /* LocationsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFACE01000000000000A003 /* LocationsRepository.swift */; }; 14DECF062A3A68B0005BC2AE /* Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14DECF052A3A68B0005BC2AE /* Location.swift */; }; 14DECF082A3A7F76005BC2AE /* Order.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14DECF072A3A7F76005BC2AE /* Order.swift */; }; 14DECF0D2A3BDC27005BC2AE /* POSView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14DECF0C2A3BDC27005BC2AE /* POSView.swift */; }; 14DECF0F2A3BDC31005BC2AE /* KDSView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14DECF0E2A3BDC31005BC2AE /* KDSView.swift */; }; 14DECF112A3BDC3A005BC2AE /* LocationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14DECF102A3BDC3A005BC2AE /* LocationsView.swift */; }; - 14DECF152A3CCAB6005BC2AE /* POS_VM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14DECF142A3CCAB6005BC2AE /* POS_VM.swift */; }; + DAFACE01000000000000B006 /* LocationsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFACE01000000000000A007 /* LocationsViewModel.swift */; }; + 14DECF152A3CCAB6005BC2AE /* POSViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14DECF142A3CCAB6005BC2AE /* POSViewModel.swift */; }; 14DECF172A3CFF7D005BC2AE /* SaleItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14DECF162A3CFF7D005BC2AE /* SaleItem.swift */; }; 14DECF192A3D1254005BC2AE /* SaleItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14DECF182A3D1254005BC2AE /* SaleItemView.swift */; }; 14DECF1B2A3D13CF005BC2AE /* POSGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14DECF1A2A3D13CF005BC2AE /* POSGridView.swift */; }; @@ -70,22 +75,27 @@ 140421CE2B9AB6B700778C1B /* AdvancedSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettings.swift; sourceTree = ""; }; 1414DEC12AA0F8D600D9CE87 /* KDSOrderItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KDSOrderItemView.swift; sourceTree = ""; }; 1460E33A2A93DA5D007A2A6D /* KDSOrderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KDSOrderView.swift; sourceTree = ""; }; - 1460E33C2A93DCC8007A2A6D /* KDS_VM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KDS_VM.swift; sourceTree = ""; }; + 1460E33C2A93DCC8007A2A6D /* KDSViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KDSViewModel.swift; sourceTree = ""; }; 148071152A8C2E930091EC03 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 149CABC52A32C43700729331 /* Env.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Env.swift; sourceTree = ""; }; 14CD51AD2A2FE2D50018DE87 /* DittoPOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DittoPOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; 14CD51B02A2FE2D50018DE87 /* DittoPOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoPOS.swift; sourceTree = ""; }; 14CD51B22A2FE2D50018DE87 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; + DAFACE01000000000000A006 /* MainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewModel.swift; sourceTree = ""; }; 14CD51B42A2FE2D60018DE87 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 14CD51B72A2FE2D60018DE87 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - 14CD51BE2A2FE3EE0018DE87 /* DittoService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DittoService.swift; sourceTree = ""; }; + 14CD51BE2A2FE3EE0018DE87 /* DittoManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DittoManager.swift; sourceTree = ""; }; + DAFACE01000000000000A001 /* OrdersRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrdersRepository.swift; sourceTree = ""; }; + DAFACE01000000000000A002 /* SaleItemsRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaleItemsRepository.swift; sourceTree = ""; }; + DAFACE01000000000000A003 /* LocationsRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsRepository.swift; sourceTree = ""; }; 14CD51C22A2FE4880018DE87 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 14DECF052A3A68B0005BC2AE /* Location.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Location.swift; sourceTree = ""; }; 14DECF072A3A7F76005BC2AE /* Order.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Order.swift; sourceTree = ""; }; 14DECF0C2A3BDC27005BC2AE /* POSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSView.swift; sourceTree = ""; }; 14DECF0E2A3BDC31005BC2AE /* KDSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KDSView.swift; sourceTree = ""; }; 14DECF102A3BDC3A005BC2AE /* LocationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsView.swift; sourceTree = ""; }; - 14DECF142A3CCAB6005BC2AE /* POS_VM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POS_VM.swift; sourceTree = ""; }; + DAFACE01000000000000A007 /* LocationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsViewModel.swift; sourceTree = ""; }; + 14DECF142A3CCAB6005BC2AE /* POSViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSViewModel.swift; sourceTree = ""; }; 14DECF162A3CFF7D005BC2AE /* SaleItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaleItem.swift; sourceTree = ""; }; 14DECF182A3D1254005BC2AE /* SaleItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaleItemView.swift; sourceTree = ""; }; 14DECF1A2A3D13CF005BC2AE /* POSGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSGridView.swift; sourceTree = ""; }; @@ -133,7 +143,7 @@ 14039BEC2A465ECC001903ED /* POS */ = { isa = PBXGroup; children = ( - 14DECF142A3CCAB6005BC2AE /* POS_VM.swift */, + 14DECF142A3CCAB6005BC2AE /* POSViewModel.swift */, 14DECF1A2A3D13CF005BC2AE /* POSGridView.swift */, 14FFFEEE2A43639A00DD6806 /* POSOrderItemView.swift */, 14039BED2A465F37001903ED /* POSOrderTotalView.swift */, @@ -148,6 +158,7 @@ isa = PBXGroup; children = ( 14DECF102A3BDC3A005BC2AE /* LocationsView.swift */, + DAFACE01000000000000A007 /* LocationsViewModel.swift */, ); path = Locations; sourceTree = ""; @@ -155,7 +166,7 @@ 1420B7992A4A23CD00EBF40F /* KDS */ = { isa = PBXGroup; children = ( - 1460E33C2A93DCC8007A2A6D /* KDS_VM.swift */, + 1460E33C2A93DCC8007A2A6D /* KDSViewModel.swift */, 14DECF202A3D16DF005BC2AE /* KDSOrdersGridView.swift */, 1414DEC12AA0F8D600D9CE87 /* KDSOrderItemView.swift */, 1460E33A2A93DA5D007A2A6D /* KDSOrderView.swift */, @@ -195,6 +206,7 @@ children = ( 14CD51B02A2FE2D50018DE87 /* DittoPOS.swift */, 14CD51B22A2FE2D50018DE87 /* MainView.swift */, + DAFACE01000000000000A006 /* MainViewModel.swift */, 14DECF042A3A6868005BC2AE /* Data */, 1420B7992A4A23CD00EBF40F /* KDS */, 1420B7982A4A23AC00EBF40F /* Locations */, @@ -231,7 +243,7 @@ 14DECF042A3A6868005BC2AE /* Data */ = { isa = PBXGroup; children = ( - 14CD51BE2A2FE3EE0018DE87 /* DittoService.swift */, + 14CD51BE2A2FE3EE0018DE87 /* DittoManager.swift */, 112311FE2B3DBA0100A04E44 /* DittoExtensions.swift */, 14DECF052A3A68B0005BC2AE /* Location.swift */, 14DECF162A3CFF7D005BC2AE /* SaleItem.swift */, @@ -242,11 +254,22 @@ DAFEED01000000000000A004 /* ImageNameMapping.swift */, DAFEED01000000000000A005 /* OrderStatusDisplay.swift */, DAFEED01000000000000A00A /* DocumentID.swift */, + DAFACE01000000000000A005 /* Repositories */, DAFEED01000000000000A006 /* Demo */, ); path = Data; sourceTree = ""; }; + DAFACE01000000000000A005 /* Repositories */ = { + isa = PBXGroup; + children = ( + DAFACE01000000000000A001 /* OrdersRepository.swift */, + DAFACE01000000000000A002 /* SaleItemsRepository.swift */, + DAFACE01000000000000A003 /* LocationsRepository.swift */, + ); + path = Repositories; + sourceTree = ""; + }; 14DECF0B2A3BDC0A005BC2AE /* Settings */ = { isa = PBXGroup; children = ( @@ -409,9 +432,13 @@ buildActionMask = 2147483647; files = ( 14CD51B32A2FE2D50018DE87 /* MainView.swift in Sources */, - 1460E33D2A93DCC8007A2A6D /* KDS_VM.swift in Sources */, + DAFACE01000000000000B005 /* MainViewModel.swift in Sources */, + 1460E33D2A93DCC8007A2A6D /* KDSViewModel.swift in Sources */, 14DECF1D2A3D13D9005BC2AE /* POSOrderView.swift in Sources */, - 14CD51BF2A2FE3EE0018DE87 /* DittoService.swift in Sources */, + 14CD51BF2A2FE3EE0018DE87 /* DittoManager.swift in Sources */, + DAFACE01000000000000B001 /* OrdersRepository.swift in Sources */, + DAFACE01000000000000B002 /* SaleItemsRepository.swift in Sources */, + DAFACE01000000000000B003 /* LocationsRepository.swift in Sources */, DAFEED01000000000000B001 /* CartLineItem.swift in Sources */, DAFEED01000000000000B002 /* Payment.swift in Sources */, DAFEED01000000000000B003 /* StatusLog.swift in Sources */, @@ -435,11 +462,12 @@ 14DECF1F2A3D13F0005BC2AE /* Utils.swift in Sources */, 14DECF212A3D16DF005BC2AE /* KDSOrdersGridView.swift in Sources */, 14DECF112A3BDC3A005BC2AE /* LocationsView.swift in Sources */, + DAFACE01000000000000B006 /* LocationsViewModel.swift in Sources */, 148071162A8C2E930091EC03 /* SettingsView.swift in Sources */, 07F1B1FD2C2B690A007AB5A2 /* SettingsModel.swift in Sources */, 14DECF1B2A3D13CF005BC2AE /* POSGridView.swift in Sources */, - 14DECF152A3CCAB6005BC2AE /* POS_VM.swift in Sources */, + 14DECF152A3CCAB6005BC2AE /* POSViewModel.swift in Sources */, 14FFFEEF2A43639A00DD6806 /* POSOrderItemView.swift in Sources */, 14DECF082A3A7F76005BC2AE /* Order.swift in Sources */, 14DECF0D2A3BDC27005BC2AE /* POSView.swift in Sources */, diff --git a/iOS/DittoPOS/Data/DittoExtensions.swift b/iOS/DittoPOS/Data/DittoExtensions.swift index 4a5ee92..b4dbcb2 100644 --- a/iOS/DittoPOS/Data/DittoExtensions.swift +++ b/iOS/DittoPOS/Data/DittoExtensions.swift @@ -18,9 +18,9 @@ import Foundation /// in DQL filters like `WHERE createdAt > :TTL`. enum DittoWireDate { static let formatter: ISO8601DateFormatter = { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return f + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter }() static func string(from date: Date) -> String { formatter.string(from: date) } diff --git a/iOS/DittoPOS/Data/DittoManager.swift b/iOS/DittoPOS/Data/DittoManager.swift new file mode 100644 index 0000000..d7f6f4d --- /dev/null +++ b/iOS/DittoPOS/Data/DittoManager.swift @@ -0,0 +1,84 @@ +// +// DittoManager.swift +// DittoPOS +// +// Copyright © 2026 DittoLive Incorporated. All rights reserved. +// + +import Combine +import DittoSwift + +/// Owns the `Ditto` instance and starts sync. Holds transport-level +/// configuration including the sync group and routing hint, both of which +/// are driven by the currently active location. Mirrors Android's +/// `DittoManager` (in the `ditto-wrapper` module). +final class DittoManager: ObservableObject { + static var shared = DittoManager() + let ditto: Ditto + + private init() { + #if os(tvOS) + let directory: FileManager.SearchPathDirectory = .cachesDirectory + #else + let directory: FileManager.SearchPathDirectory = .documentDirectory + #endif + + let persistenceDirURL = try? FileManager() + .url(for: directory, in: .userDomainMask, appropriateFor: nil, create: true) + .appendingPathComponent("ditto-pos-demo") + + ditto = Ditto(identity: .onlinePlayground( + appID: Env.DITTO_APP_ID, + token: Env.DITTO_PLAYGROUND_TOKEN, + enableDittoCloudSync: false + ), persistenceDirectory: persistenceDirURL) + + ditto.updateTransportConfig { transportConfig in + transportConfig.connect.webSocketURLs.insert(Env.DITTO_WEBSOCKET_URL) + } + + do { + try ditto.disableSyncWithV3() + } catch { + print("ERROR: disableSyncWithV3() failed: \(error)") + } + + Task { + do { + // strict mode off lets DQL use map/object CRDT semantics + try await ditto.store.execute(query: "ALTER SYSTEM SET DQL_STRICT_MODE = false") + let isPreview: Bool = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + if !isPreview { + try ditto.sync.start() + } + } catch { + print("ERROR: starting sync failed: \(error)") + } + } + } + + /// Restart sync with a fresh routing config when the active location + /// changes. `setRoutingConfig` only takes effect at sync start, so we + /// bounce it. + func applyRoutingConfig(locationId: String) { + ditto.sync.stop() + setRoutingConfig(locationId: locationId) + do { try ditto.sync.start() } catch { + print("Failed to restart sync: \(error)") + } + } + + /// Sets the sync group and routing hint to the numeric location id so + /// devices at the same location form an isolated peer-to-peer mesh and + /// the Big Peer can co-locate their data. + /// + /// - https://docs.ditto.live/sdk/latest/sync/creating-sync-groups + /// - https://docs.ditto.live/sdk/latest/deployment/setting-routing-hints + func setRoutingConfig(locationId: String) { + guard let value = UInt32(locationId) else { return } + ditto.updateTransportConfig { config in + config.global.syncGroup = value + config.global.routingHint = value + } + } +} diff --git a/iOS/DittoPOS/Data/DittoService.swift b/iOS/DittoPOS/Data/DittoService.swift deleted file mode 100644 index 836a4f3..0000000 --- a/iOS/DittoPOS/Data/DittoService.swift +++ /dev/null @@ -1,342 +0,0 @@ -// -// DittoService.swift -// DittoPOS -// -// Copyright © 2026 DittoLive Incorporated. All rights reserved. -// - -import Combine -import DittoSwift - -// MARK: - DittoInstance - -/// Owns the `Ditto` instance and starts sync. Split out so `DittoService` -/// can stay focused on this app's collections and lifecycle. -final class DittoInstance: ObservableObject { - static var shared = DittoInstance() - let ditto: Ditto - - private init() { - #if os(tvOS) - let directory: FileManager.SearchPathDirectory = .cachesDirectory - #else - let directory: FileManager.SearchPathDirectory = .documentDirectory - #endif - - let persistenceDirURL = try? FileManager() - .url(for: directory, in: .userDomainMask, appropriateFor: nil, create: true) - .appendingPathComponent("ditto-pos-demo") - - ditto = Ditto(identity: .onlinePlayground( - appID: Env.DITTO_APP_ID, - token: Env.DITTO_PLAYGROUND_TOKEN, - enableDittoCloudSync: false - ), persistenceDirectory: persistenceDirURL) - - ditto.updateTransportConfig { transportConfig in - transportConfig.connect.webSocketURLs.insert(Env.DITTO_WEBSOCKET_URL) - } - - do { - try ditto.disableSyncWithV3() - } catch { - print("ERROR: disableSyncWithV3() failed: \(error)") - } - - Task { - do { - // strict mode off lets DQL use map/object CRDT semantics - try await ditto.store.execute(query: "ALTER SYSTEM SET DQL_STRICT_MODE = false") - let isPreview: Bool = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" - if !isPreview { - try ditto.sync.start() - } - } catch { - print("ERROR: starting sync failed: \(error)") - } - } - } -} - -// MARK: - DittoService -// -// Single point of contact between the UI and Ditto: holds the @Published -// state the views observe, registers subscriptions, runs DQL mutations, -// and performs launch-time eviction. Mirrors the Android `DittoRepository` -// shape (one type, no internal sub-services). - -@MainActor final class DittoService: ObservableObject { - static let shared = DittoService() - - @Published private(set) var allLocations: [Location] = [] - @Published private(set) var currentLocation: Location? - @Published var currentLocationId: String? - @Published private(set) var locationOrders: [Order] = [] - @Published private(set) var locationSaleItems: [SaleItem] = [] - - let ditto = DittoInstance.shared.ditto - private var store: DittoStore { ditto.store } - private var sync: DittoSync { ditto.sync } - - // Active subscriptions keyed by stable name so we can replace on location change. - private var subscriptions: [String: DittoSyncSubscription] = [:] - private var cancellables = Set() - private var locationsObserver = AnyCancellable({}) - private var ordersObserver = AnyCancellable({}) - private var saleItemsObserver = AnyCancellable({}) - - private init() { - // Locations sync starts immediately; orders + sale_items wait for a chosen location. - registerSubscription(name: "locations", query: "SELECT * FROM \(Location.collectionName)") - - Task { @MainActor in - await DemoSeeder(store: store).seedAll() - await Eviction.runIfDue(store: store) - } - - observeAllLocations() - currentLocationId = Settings.locationId - - $currentLocationId - .combineLatest($allLocations) - .sink { [weak self] locationId, locations in - guard let self, let locationId else { return } - - if locationId != Settings.locationId { - Settings.locationId = locationId - } - - self.activate(locationId: locationId, locations: locations) - } - .store(in: &cancellables) - } - - // MARK: Public — mutations - - func add(order: Order) { - upsert(order: order) - } - - func add(item: CartLineItem, lineItemId: String, to order: Order) { - upsert(order: order.addingCartLineItem(item, lineItemId: lineItemId)) - } - - func updateStatus(of order: Order, with status: OrderStatus) { - upsert(order: order.appendingStatus(status)) - } - - func addPayment(_ payment: Payment, paymentId: String, to order: Order) { - upsert(order: order.addingPayment(payment, paymentId: paymentId)) - } - - func clearCart(of order: Order) { - guard !order.cart.isEmpty else { return } - let unsetList = order.cart.keys.map { "cart.\"\($0)\"" }.joined(separator: ", ") - execute( - """ - UPDATE \(Order.collectionName) - UNSET \(unsetList) - WHERE _id.id = :id AND _id.locationId = :locationId - """, - args: ["id": order.documentId.id, "locationId": order.documentId.locationId] - ) - } - - func reset(order: Order) { - let createdAtNow = DittoWireDate.string(from: Date()) - var args: [String: Any?] = [ - "id": order.documentId.id, - "locationId": order.documentId.locationId, - "createdAt": createdAtNow - ] - let setClause = "SET createdAt = :createdAt" - let whereClause = "WHERE _id.id = :id AND _id.locationId = :locationId" - - if order.cart.isEmpty { - execute("UPDATE \(Order.collectionName) \(setClause) \(whereClause)", args: args) - } else { - let unsetList = order.cart.keys.map { "cart.\"\($0)\"" }.joined(separator: ", ") - execute( - "UPDATE \(Order.collectionName) \(setClause) UNSET \(unsetList) \(whereClause)", - args: args - ) - } - } - - // MARK: Public — observers - - /// Observe a single order by composite id. - func orderPublisher(_ order: Order) -> AnyPublisher { - store.observePublisher( - query: """ - SELECT * FROM \(Order.collectionName) - WHERE _id.id = :id AND _id.locationId = :locationId - """, - arguments: ["id": order.documentId.id, "locationId": order.documentId.locationId], - mapTo: Order.self - ) - .compactMap(\.first) - .catch { _ in Empty() } - .eraseToAnyPublisher() - } - - // MARK: Private - - private func upsert(order: Order) { - guard let json = try? order.dittoJSONString() else { return } - execute( - """ - INSERT INTO \(Order.collectionName) - DOCUMENTS (deserialize_json(:json)) - ON ID CONFLICT DO UPDATE_LOCAL_DIFF - """, - args: ["json": json] - ) - } - - private func activate(locationId: String, locations: [Location]) { - applyRoutingConfig(locationId: locationId) - registerSubscription( - name: "orders", - query: """ - SELECT * FROM \(Order.collectionName) - WHERE _id.locationId = :locationId - AND createdAt > :TTL - """, - args: ["locationId": locationId, "TTL": DateFormatter.startOfTodayString] - ) - registerSubscription( - name: "sale_items", - query: """ - SELECT * FROM \(SaleItem.collectionName) - WHERE _id.locationId = :locationId - ORDER BY name - """, - args: ["locationId": locationId] - ) - - observeOrders(locationId: locationId) - observeSaleItems(locationId: locationId) - currentLocation = locations.first { $0.id == locationId } - } - - private func registerSubscription(name: String, query: String, args: [String: Any?]? = nil) { - subscriptions[name]?.cancel() - do { - subscriptions[name] = try sync.registerSubscription(query: query, arguments: args) - } catch { - assertionFailure("subscribe \(name) failed: \(error.localizedDescription)") - } - } - - /// Restart sync with a fresh routing config when the location changes. - /// `setRoutingConfig` only takes effect at sync start, so we bounce it. - private func applyRoutingConfig(locationId: String) { - ditto.sync.stop() - setRoutingConfig(locationId: locationId) - do { try ditto.sync.start() } catch { - print("Failed to restart sync: \(error)") - } - } - - /// Sets the sync group and routing hint to the numeric location id so - /// devices at the same location form an isolated peer-to-peer mesh and - /// the Big Peer can co-locate their data. - /// - /// - https://docs.ditto.live/sdk/latest/sync/creating-sync-groups - /// - https://docs.ditto.live/sdk/latest/deployment/setting-routing-hints - private func setRoutingConfig(locationId: String) { - guard let value = UInt32(locationId) else { return } - ditto.updateTransportConfig { config in - config.global.syncGroup = value - config.global.routingHint = value - } - } - - private func observeAllLocations() { - locationsObserver = store.observePublisher( - query: "SELECT * FROM \(Location.collectionName)", - mapTo: Location.self - ) - .map { locations in - locations.filter { LocationSeed.demoLocationIds.contains($0.id) } - } - .replaceError(with: []) - .assign(to: \.allLocations, on: self) - } - - private func observeOrders(locationId: String) { - guard let sub = subscriptions["orders"] else { return } - ordersObserver = store.observePublisher( - query: sub.queryString, - arguments: sub.queryArguments, - mapTo: Order.self - ) - .replaceError(with: []) - .assign(to: \.locationOrders, on: self) - } - - private func observeSaleItems(locationId: String) { - guard let sub = subscriptions["sale_items"] else { return } - saleItemsObserver = store.observePublisher( - query: sub.queryString, - arguments: sub.queryArguments, - mapTo: SaleItem.self - ) - .replaceError(with: []) - .assign(to: \.locationSaleItems, on: self) - } - - private func execute(_ query: String, args: [String: Any?] = [:], function: String = #function) { - Task { - do { - _ = try await store.execute(query: query, arguments: args) - } catch { - assertionFailure("DQL \(function) failed: \(error.localizedDescription)\n\(query)") - } - } - } -} - -// MARK: - Location reset - -extension DittoService { - var locationSetupNotValid: Bool { - Settings.locationId == nil - } - - func resetLocationSelection() { - Settings.locationId = nil - currentLocation = nil - currentLocationId = nil - } -} - -// MARK: - Eviction -// -// Storage cleanup on app launch, gated to at most once per 24h. Observer -// queries filter by location/TTL, so this is purely about preventing the -// local store from accumulating expired orders. - -private enum Eviction { - private static let lastRunKey = "v2.lastEvictionAt" - private static let interval: TimeInterval = 60 * 60 * 24 - - static func runIfDue(store: DittoStore) async { - let now = Date().timeIntervalSince1970 - let last = UserDefaults.standard.double(forKey: lastRunKey) - guard now - last >= interval else { return } - - let ttl = DateFormatter.startOfTodayString - do { - _ = try await store.execute( - query: "EVICT FROM \(Order.collectionName) WHERE createdAt <= :TTL", - arguments: ["TTL": ttl] - ) - UserDefaults.standard.set(now, forKey: lastRunKey) - print("Eviction: evicted orders with createdAt <= \(ttl)") - } catch { - print("Eviction: ERROR \(error.localizedDescription)") - } - } -} diff --git a/iOS/DittoPOS/Data/Repositories/LocationsRepository.swift b/iOS/DittoPOS/Data/Repositories/LocationsRepository.swift new file mode 100644 index 0000000..31c8207 --- /dev/null +++ b/iOS/DittoPOS/Data/Repositories/LocationsRepository.swift @@ -0,0 +1,85 @@ +// +// LocationsRepository.swift +// DittoPOS +// +// Copyright © 2026 DittoLive Incorporated. All rights reserved. +// + +import Combine +import DittoSwift + +/// Owns the `locations` collection and the in-memory state for the currently +/// selected location. `allLocations` is filtered to the seven demo location +/// IDs defensively. `currentLocationId` is the single source of truth for +/// "which location is active" — UI calls `setActive(_:)` to switch and the +/// per-collection repositories react via their own subscriptions to +/// `$currentLocationId`. `currentLocation` is a convenience derivation for +/// views. +@MainActor final class LocationsRepository: ObservableObject { + + @Published private(set) var allLocations: [Location] = [] + @Published private(set) var currentLocationId: String? + @Published private(set) var currentLocation: Location? + + private let dittoManager: DittoManager + private var ditto: Ditto { dittoManager.ditto } + private var store: DittoStore { ditto.store } + private var sync: DittoSync { ditto.sync } + + private var subscription: DittoSyncSubscription? + private var locationsObserver = AnyCancellable({}) + private var currentLocationDerivation = AnyCancellable({}) + + init(dittoManager: DittoManager = .shared) { + self.dittoManager = dittoManager + startSubscription() + observeAllLocations() + deriveCurrentLocation() + // Restore the persisted active location on launch. Passes through + // the same setter the UI uses, so routing config is applied here too. + if let saved = Settings.locationId { + setActiveLocation(saved) + } + } + + /// Switch the active location. Persists, publishes, and reconfigures + /// Ditto's routing. Pass `nil` to clear the selection. + func setActiveLocation(_ locationId: String?) { + Settings.locationId = locationId + currentLocationId = locationId + if let locationId { + dittoManager.applyRoutingConfig(locationId: locationId) + } + } + + private func startSubscription() { + do { + subscription = try sync.registerSubscription( + query: "SELECT * FROM \(Location.collectionName)" + ) + } catch { + assertionFailure("subscribe locations failed: \(error.localizedDescription)") + } + } + + private func observeAllLocations() { + locationsObserver = store.observePublisher( + query: "SELECT * FROM \(Location.collectionName)", + mapTo: Location.self + ) + .map { locations in + locations.filter { LocationSeed.demoLocationIds.contains($0.id) } + } + .replaceError(with: []) + .assign(to: \.allLocations, on: self) + } + + private func deriveCurrentLocation() { + currentLocationDerivation = Publishers.CombineLatest($currentLocationId, $allLocations) + .map { id, all in + guard let id else { return nil } + return all.first { $0.id == id } + } + .assign(to: \.currentLocation, on: self) + } +} diff --git a/iOS/DittoPOS/Data/Repositories/OrdersRepository.swift b/iOS/DittoPOS/Data/Repositories/OrdersRepository.swift new file mode 100644 index 0000000..cd83be3 --- /dev/null +++ b/iOS/DittoPOS/Data/Repositories/OrdersRepository.swift @@ -0,0 +1,187 @@ +// +// OrdersRepository.swift +// DittoPOS +// +// Copyright © 2026 DittoLive Incorporated. All rights reserved. +// + +import Combine +import DittoSwift + +/// Owns the `pos_orders` collection: registers the per-location subscription, +/// observes the synced documents, runs DQL mutations, and gates startup +/// eviction. Mirrors Android's `OrdersRepository`. +@MainActor final class OrdersRepository: ObservableObject { + + @Published private(set) var locationOrders: [Order] = [] + + private let dittoManager: DittoManager + private var ditto: Ditto { dittoManager.ditto } + private var store: DittoStore { ditto.store } + private var sync: DittoSync { ditto.sync } + + private var subscription: DittoSyncSubscription? + private var observer = AnyCancellable({}) + private var locationSubscription = AnyCancellable({}) + + init(dittoManager: DittoManager = .shared, locationsRepository: LocationsRepository) { + self.dittoManager = dittoManager + // Pull the active location from LocationsRepository and re-register + // the subscription on every change. @Published replays the current + // value on subscribe, so cold-start activation comes through here. + locationSubscription = locationsRepository.$currentLocationId + .compactMap { $0 } + .removeDuplicates() + .sink { [weak self] locationId in + self?.setActiveLocation(locationId) + } + } + + // MARK: Subscription + + private func setActiveLocation(_ locationId: String) { + subscription?.cancel() + do { + let sub = try sync.registerSubscription( + query: """ + SELECT * FROM \(Order.collectionName) + WHERE _id.locationId = :locationId + AND createdAt > :TTL + """, + arguments: ["locationId": locationId, "TTL": DateFormatter.startOfTodayString] + ) + subscription = sub + + observer = store.observePublisher( + query: sub.queryString, + arguments: sub.queryArguments, + mapTo: Order.self + ) + .replaceError(with: []) + .assign(to: \.locationOrders, on: self) + } catch { + assertionFailure("subscribe orders failed: \(error.localizedDescription)") + } + } + + // MARK: Single-order observation (used by KDS tile views) + + func orderPublisher(_ order: Order) -> AnyPublisher { + store.observePublisher( + query: """ + SELECT * FROM \(Order.collectionName) + WHERE _id.id = :id AND _id.locationId = :locationId + """, + arguments: ["id": order.documentId.id, "locationId": order.documentId.locationId], + mapTo: Order.self + ) + .compactMap(\.first) + .catch { _ in Empty() } + .eraseToAnyPublisher() + } + + // MARK: Mutations + + func add(order: Order) { + upsert(order: order) + } + + func add(item: CartLineItem, lineItemId: String, to order: Order) { + upsert(order: order.addingCartLineItem(item, lineItemId: lineItemId)) + } + + func updateStatus(of order: Order, with status: OrderStatus) { + upsert(order: order.appendingStatus(status)) + } + + func addPayment(_ payment: Payment, paymentId: String, to order: Order) { + upsert(order: order.addingPayment(payment, paymentId: paymentId)) + } + + func clearCart(of order: Order) { + guard !order.cart.isEmpty else { return } + let unsetList = order.cart.keys.map { "cart.\"\($0)\"" }.joined(separator: ", ") + execute( + """ + UPDATE \(Order.collectionName) + UNSET \(unsetList) + WHERE _id.id = :id AND _id.locationId = :locationId + """, + args: ["id": order.documentId.id, "locationId": order.documentId.locationId] + ) + } + + func reset(order: Order) { + let createdAtNow = DittoWireDate.string(from: Date()) + let args: [String: Any?] = [ + "id": order.documentId.id, + "locationId": order.documentId.locationId, + "createdAt": createdAtNow + ] + let setClause = "SET createdAt = :createdAt" + let whereClause = "WHERE _id.id = :id AND _id.locationId = :locationId" + + if order.cart.isEmpty { + execute("UPDATE \(Order.collectionName) \(setClause) \(whereClause)", args: args) + } else { + let unsetList = order.cart.keys.map { "cart.\"\($0)\"" }.joined(separator: ", ") + execute( + "UPDATE \(Order.collectionName) \(setClause) UNSET \(unsetList) \(whereClause)", + args: args + ) + } + } + + // MARK: Eviction + // + // Storage cleanup on app launch, gated to at most once per 24h. Observer + // queries filter by location/TTL, so this is purely about preventing the + // local store from accumulating expired orders. + + func runEvictionIfDue() async { + let now = Date().timeIntervalSince1970 + let last = UserDefaults.standard.double(forKey: Eviction.lastRunKey) + guard now - last >= Eviction.interval else { return } + + let ttl = DateFormatter.startOfTodayString + do { + _ = try await store.execute( + query: "EVICT FROM \(Order.collectionName) WHERE createdAt <= :TTL", + arguments: ["TTL": ttl] + ) + UserDefaults.standard.set(now, forKey: Eviction.lastRunKey) + print("Eviction: evicted orders with createdAt <= \(ttl)") + } catch { + print("Eviction: ERROR \(error.localizedDescription)") + } + } + + private enum Eviction { + static let lastRunKey = "v2.lastEvictionAt" + static let interval: TimeInterval = 60 * 60 * 24 + } + + // MARK: Helpers + + private func upsert(order: Order) { + guard let json = try? order.dittoJSONString() else { return } + execute( + """ + INSERT INTO \(Order.collectionName) + DOCUMENTS (deserialize_json(:json)) + ON ID CONFLICT DO UPDATE_LOCAL_DIFF + """, + args: ["json": json] + ) + } + + private func execute(_ query: String, args: [String: Any?] = [:], function: String = #function) { + Task { + do { + _ = try await store.execute(query: query, arguments: args) + } catch { + assertionFailure("DQL \(function) failed: \(error.localizedDescription)\n\(query)") + } + } + } +} diff --git a/iOS/DittoPOS/Data/Repositories/SaleItemsRepository.swift b/iOS/DittoPOS/Data/Repositories/SaleItemsRepository.swift new file mode 100644 index 0000000..b572a36 --- /dev/null +++ b/iOS/DittoPOS/Data/Repositories/SaleItemsRepository.swift @@ -0,0 +1,61 @@ +// +// SaleItemsRepository.swift +// DittoPOS +// +// Copyright © 2026 DittoLive Incorporated. All rights reserved. +// + +import Combine +import DittoSwift + +/// Owns the `sale_items` collection: registers the per-location subscription +/// and exposes the synced menu items to consumers. Mirrors Android's +/// `SaleItemsRepository`. +@MainActor final class SaleItemsRepository: ObservableObject { + + @Published private(set) var locationSaleItems: [SaleItem] = [] + + private let dittoManager: DittoManager + private var ditto: Ditto { dittoManager.ditto } + private var store: DittoStore { ditto.store } + private var sync: DittoSync { ditto.sync } + + private var subscription: DittoSyncSubscription? + private var observer = AnyCancellable({}) + private var locationSubscription = AnyCancellable({}) + + init(dittoManager: DittoManager = .shared, locationsRepository: LocationsRepository) { + self.dittoManager = dittoManager + locationSubscription = locationsRepository.$currentLocationId + .compactMap { $0 } + .removeDuplicates() + .sink { [weak self] locationId in + self?.setActiveLocation(locationId) + } + } + + private func setActiveLocation(_ locationId: String) { + subscription?.cancel() + do { + let sub = try sync.registerSubscription( + query: """ + SELECT * FROM \(SaleItem.collectionName) + WHERE _id.locationId = :locationId + ORDER BY name + """, + arguments: ["locationId": locationId] + ) + subscription = sub + + observer = store.observePublisher( + query: sub.queryString, + arguments: sub.queryArguments, + mapTo: SaleItem.self + ) + .replaceError(with: []) + .assign(to: \.locationSaleItems, on: self) + } catch { + assertionFailure("subscribe sale_items failed: \(error.localizedDescription)") + } + } +} diff --git a/iOS/DittoPOS/Data/StatusLog.swift b/iOS/DittoPOS/Data/StatusLog.swift index c739dee..f0e50db 100644 --- a/iOS/DittoPOS/Data/StatusLog.swift +++ b/iOS/DittoPOS/Data/StatusLog.swift @@ -38,9 +38,9 @@ enum StatusLogDerivation { static func currentStatus(from log: [String: String], default defaultStatus: OrderStatus = .open) -> OrderStatus { guard !log.isEmpty else { return defaultStatus } - let entries: [(timestamp: String, status: OrderStatus)] = log.compactMap { ts, raw in - guard let s = OrderStatus(rawValue: raw) else { return nil } - return (ts, s) + let entries: [(timestamp: String, status: OrderStatus)] = log.compactMap { timestamp, raw in + guard let status = OrderStatus(rawValue: raw) else { return nil } + return (timestamp, status) } guard !entries.isEmpty else { return defaultStatus } diff --git a/iOS/DittoPOS/DittoPOS.swift b/iOS/DittoPOS/DittoPOS.swift index 7fef1b5..d78fd7c 100644 --- a/iOS/DittoPOS/DittoPOS.swift +++ b/iOS/DittoPOS/DittoPOS.swift @@ -10,9 +10,36 @@ import SwiftUI @main struct DittoPOS: App { + @StateObject private var locationsRepository: LocationsRepository + @StateObject private var ordersRepository: OrdersRepository + @StateObject private var saleItemsRepository: SaleItemsRepository + + init() { + let manager = DittoManager.shared + let locationsRepository = LocationsRepository(dittoManager: manager) + let ordersRepository = OrdersRepository( + dittoManager: manager, + locationsRepository: locationsRepository + ) + let saleItemsRepository = SaleItemsRepository( + dittoManager: manager, + locationsRepository: locationsRepository + ) + _locationsRepository = StateObject(wrappedValue: locationsRepository) + _ordersRepository = StateObject(wrappedValue: ordersRepository) + _saleItemsRepository = StateObject(wrappedValue: saleItemsRepository) + } + var body: some Scene { WindowGroup { - MainView() + MainView(locationsRepository: locationsRepository) + .environmentObject(locationsRepository) + .environmentObject(ordersRepository) + .environmentObject(saleItemsRepository) + .task { + await DemoSeeder(store: DittoManager.shared.ditto.store).seedAll() + await ordersRepository.runEvictionIfDue() + } } } } diff --git a/iOS/DittoPOS/KDS/KDSOrderView.swift b/iOS/DittoPOS/KDS/KDSOrderView.swift index 7bb4253..f7f2ee2 100644 --- a/iOS/DittoPOS/KDS/KDSOrderView.swift +++ b/iOS/DittoPOS/KDS/KDSOrderView.swift @@ -8,51 +8,63 @@ import Combine import SwiftUI -@MainActor class KDSOrderVM: ObservableObject { +@MainActor class KDSOrderViewModel: ObservableObject { @Published var order: Order + private let ordersRepository: OrdersRepository private var cancellables = Set() - init(_ order: Order) { + init(_ order: Order, ordersRepository: OrdersRepository) { self.order = order + self.ordersRepository = ordersRepository - DittoService.shared.orderPublisher(order) + ordersRepository.orderPublisher(order) .filter { $0.status == .inProcess || $0.status == .processed } - .sink {[weak self] updatedOrder in + .sink { [weak self] updatedOrder in self?.order = updatedOrder } .store(in: &cancellables) } + // MARK: Derived UI state + + var titleText: String { order.title } + var timestampText: String { DateFormatter.shortTime.string(from: order.createdAt) } + var headerText: String { "\(timestampText) #\(titleText)" } + var statusColor: Color { order.status.color } + var statusTitle: String { order.status.title } + var summaryEntries: [(key: String, value: Int)] { order.summary.sorted(by: <) } + var isPaid: Bool { order.isPaid } + func incrementOrderStatus() { guard let next = order.status.next else { return } - DittoService.shared.updateStatus(of: order, with: next) + ordersRepository.updateStatus(of: order, with: next) } } struct KDSOrderView: View { @Environment(\.colorScheme) private var colorScheme - @StateObject var vm: KDSOrderVM + @StateObject var viewModel: KDSOrderViewModel - init(_ order: Order) { - self._vm = StateObject(wrappedValue: KDSOrderVM(order)) + init(_ order: Order, ordersRepository: OrdersRepository) { + self._viewModel = StateObject(wrappedValue: KDSOrderViewModel(order, ordersRepository: ordersRepository)) } var body: some View { VStack(alignment: .leading, spacing: 0) { - Text("\(timestampText) #\(titleText)") + Text(viewModel.headerText) .padding(4) .fixedSize(horizontal: true, vertical: false) .frame(maxWidth: .infinity) - .border(vm.order.status.color, width: 2) + .border(viewModel.statusColor, width: 2) - ForEach(vm.order.summary.sorted(by: <), id: \.key) { key, value in + ForEach(viewModel.summaryEntries, id: \.key) { key, value in divider() KDSOrderItemView(title: key, count: value) } HStack(spacing: 0) { Spacer() - if vm.order.isPaid { + if viewModel.isPaid { Image(systemName: "checkmark.circle") .foregroundColor(.black) .padding(2) @@ -60,13 +72,13 @@ struct KDSOrderView: View { } .frame(height: 35) .frame(maxWidth: .infinity) - .background(vm.order.status.color) + .background(viewModel.statusColor) #if os(tvOS) Spacer() Button(action: { - vm.incrementOrderStatus() + viewModel.incrementOrderStatus() }, label: { - Text("clear \(vm.order.status.title)") + Text("clear \(viewModel.statusTitle)") .font(.caption) }) .padding(.horizontal) @@ -74,18 +86,7 @@ struct KDSOrderView: View { } .padding(4) .onTapGesture { - vm.incrementOrderStatus() + viewModel.incrementOrderStatus() } } - - var titleText: String { vm.order.title } - var timestampText: String { - DateFormatter.shortTime.string(from: vm.order.createdAt) - } -} - -struct KDSOrderView_Previews: PreviewProvider { - static var previews: some View { - KDSOrderView(Order.preview()) - } } diff --git a/iOS/DittoPOS/KDS/KDSOrdersGridView.swift b/iOS/DittoPOS/KDS/KDSOrdersGridView.swift index 7865856..23e3956 100644 --- a/iOS/DittoPOS/KDS/KDSOrdersGridView.swift +++ b/iOS/DittoPOS/KDS/KDSOrdersGridView.swift @@ -9,22 +9,28 @@ import SwiftUI struct KDSOrdersGridView: View { @Environment(\.colorScheme) private var colorScheme - @StateObject var vm = KDS_VM() + @StateObject private var viewModel: KDSViewModel + let ordersRepository: OrdersRepository #if os(tvOS) @State var columns = [GridItem(.adaptive(minimum: 300), alignment: .top)] #else @State var columns = [GridItem(.adaptive(minimum: 172), alignment: .top)] #endif + init(ordersRepository: OrdersRepository) { + self.ordersRepository = ordersRepository + _viewModel = StateObject(wrappedValue: KDSViewModel(ordersRepository: ordersRepository)) + } + var body: some View { Group { - if vm.orders.isEmpty { + if viewModel.orders.isEmpty { emptyState } else { ScrollView(showsIndicators: false) { LazyVGrid(columns: columns) { - ForEach(vm.orders, id: \.documentId.id) { order in - KDSOrderView(order) + ForEach(viewModel.orders, id: \.documentId.id) { order in + KDSOrderView(order, ordersRepository: ordersRepository) } } .padding(.vertical, 8) @@ -49,9 +55,3 @@ struct KDSOrdersGridView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } - -struct KDSOrdersGridView_Previews: PreviewProvider { - static var previews: some View { - KDSOrdersGridView() - } -} diff --git a/iOS/DittoPOS/KDS/KDSView.swift b/iOS/DittoPOS/KDS/KDSView.swift index 156c37b..1883fde 100644 --- a/iOS/DittoPOS/KDS/KDSView.swift +++ b/iOS/DittoPOS/KDS/KDSView.swift @@ -8,13 +8,13 @@ import SwiftUI struct KDSView: View { - var body: some View { - KDSOrdersGridView() + let ordersRepository: OrdersRepository + + init(ordersRepository: OrdersRepository) { + self.ordersRepository = ordersRepository } -} -struct KDSView_Previews: PreviewProvider { - static var previews: some View { - KDSView() + var body: some View { + KDSOrdersGridView(ordersRepository: ordersRepository) } } diff --git a/iOS/DittoPOS/KDS/KDS_VM.swift b/iOS/DittoPOS/KDS/KDSViewModel.swift similarity index 76% rename from iOS/DittoPOS/KDS/KDS_VM.swift rename to iOS/DittoPOS/KDS/KDSViewModel.swift index 61e34fa..8f2282c 100644 --- a/iOS/DittoPOS/KDS/KDS_VM.swift +++ b/iOS/DittoPOS/KDS/KDSViewModel.swift @@ -1,5 +1,5 @@ // -// KDS_VM.swift +// KDSViewModel.swift // DittoPOS // // Copyright © 2026 DittoLive Incorporated. All rights reserved. @@ -10,20 +10,19 @@ import DittoSwift import Foundation /// Supplies published orders array to OrdersGridView -@MainActor class KDS_VM: ObservableObject { +@MainActor class KDSViewModel: ObservableObject { @Published private(set) var orders = [Order]() - private let dittoService = DittoService.shared private var cancellables = Set() - init(previewOrders: [Order]? = nil) { + init(ordersRepository: OrdersRepository, previewOrders: [Order]? = nil) { if let previewOrders = previewOrders { self.orders = previewOrders return } - dittoService.$locationOrders - .sink {[weak self] orders in - guard let self = self else { return } + ordersRepository.$locationOrders + .sink { [weak self] orders in + guard let self else { return } let filtered = orders.filter { $0.status == .inProcess || $0.status == .processed diff --git a/iOS/DittoPOS/Locations/LocationsView.swift b/iOS/DittoPOS/Locations/LocationsView.swift index fd052ea..05c4f9c 100644 --- a/iOS/DittoPOS/Locations/LocationsView.swift +++ b/iOS/DittoPOS/Locations/LocationsView.swift @@ -6,8 +6,6 @@ // // Copyright © 2023 DittoLive Incorporated. All rights reserved. -import Combine -import DittoSwift import SwiftUI struct LocationRowView: View { @@ -18,62 +16,23 @@ struct LocationRowView: View { } } -@MainActor class LocationsVM: ObservableObject { - @ObservedObject var dataVM = DittoService.shared - @Published var selectedItem: Location? - @Published var locations = [Location]() - private var cancellables = Set() - - init() { - dataVM.$allLocations - .sink { [weak self] locations in - self?.locations = locations - self?.selectedItem = locations.first( - where: { $0.id == self?.dataVM.currentLocationId } - ) - } - .store(in: &cancellables) - - $selectedItem - .sink {[weak self] item in - guard let item = item else { return } - if item.id != self?.dataVM.currentLocationId { +struct LocationsView: View { + @StateObject private var viewModel: LocationsViewModel - // Important: this must be called before setting dittoService.currentLocationId - // because POS_VM listens to reset incomplete orders before changing location - NotificationCenter.default.post( - name: Notification.Name("willUpdateToLocationId"), - object: item.id - ) - - print("LocationsVM.$selectedItem.sink --> SET dittoService.currentLocationId: \(item.id)") - self?.dataVM.currentLocationId = item.id - } - } - .store(in: &cancellables) + init(locationsRepository: LocationsRepository) { + _viewModel = StateObject(wrappedValue: LocationsViewModel(locationsRepository: locationsRepository)) } -} -struct LocationsView: View { - @StateObject var vm = LocationsVM() - var body: some View { VStack { - List(vm.locations, id: \.self, selection: $vm.selectedItem) { item in + List(viewModel.locations, id: \.self, selection: viewModel.selectionBinding) { item in LocationRowView(location: item) } Spacer() } -// .onAppear { print("LocationsView.onAppear") } .navigationBarTitle("Locations") #if !os(tvOS) .navigationBarTitleDisplayMode(.inline) #endif } } - -struct LocationsView_Previews: PreviewProvider { - static var previews: some View { - LocationsView() - } -} diff --git a/iOS/DittoPOS/Locations/LocationsViewModel.swift b/iOS/DittoPOS/Locations/LocationsViewModel.swift new file mode 100644 index 0000000..c42a838 --- /dev/null +++ b/iOS/DittoPOS/Locations/LocationsViewModel.swift @@ -0,0 +1,53 @@ +// +// LocationsViewModel.swift +// DittoPOS +// +// Copyright © 2026 DittoLive Incorporated. All rights reserved. +// + +import Combine +import SwiftUI + +/// Wraps `LocationsRepository` for the Locations tab: exposes the list of +/// locations and the currently selected one as `@Published`, plus a +/// `Binding` the SwiftUI `List` can drive directly. The +/// "only fire when the id actually changes" guard lives here, not in the +/// view. +@MainActor final class LocationsViewModel: ObservableObject { + + @Published private(set) var locations: [Location] = [] + @Published private(set) var selectedLocation: Location? + + private let locationsRepository: LocationsRepository + private var cancellables = Set() + + init(locationsRepository: LocationsRepository) { + self.locationsRepository = locationsRepository + + locationsRepository.$allLocations.assign(to: &$locations) + + Publishers.CombineLatest( + locationsRepository.$allLocations, + locationsRepository.$currentLocationId + ) + .map { all, id in + guard let id else { return nil as Location? } + return all.first { $0.id == id } + } + .assign(to: &$selectedLocation) + } + + /// Setter that drops no-op selections (the SwiftUI `List` will write the + /// current value back on every render). + func selectLocation(_ location: Location?) { + guard let id = location?.id, id != locationsRepository.currentLocationId else { return } + locationsRepository.setActiveLocation(id) + } + + var selectionBinding: Binding { + Binding( + get: { [weak self] in self?.selectedLocation }, + set: { [weak self] in self?.selectLocation($0) } + ) + } +} diff --git a/iOS/DittoPOS/MainView.swift b/iOS/DittoPOS/MainView.swift index 9aa947c..8a110c5 100644 --- a/iOS/DittoPOS/MainView.swift +++ b/iOS/DittoPOS/MainView.swift @@ -5,7 +5,6 @@ // Copyright © 2026 DittoLive Incorporated. All rights reserved. // -import Combine import SwiftUI enum TabViews: Int, Identifiable { @@ -13,103 +12,61 @@ enum TabViews: Int, Identifiable { var id: Self { self } } -@MainActor class MainVM: ObservableObject { - @Published var selectedTab: TabViews - @Published var presentSettingsView = false - @Published var mainTitle = DittoService.shared.currentLocation?.name ?? "Please Select Location" - private var cancellables = Set() - private var dittoService = DittoService.shared - - init() { - if Settings.locationId == nil { - selectedTab = .locations - } else { - selectedTab = Settings.selectedTabView ?? .pos - } +struct MainView: View { + @EnvironmentObject var ordersRepository: OrdersRepository + @EnvironmentObject var saleItemsRepository: SaleItemsRepository + @EnvironmentObject var locationsRepository: LocationsRepository - $selectedTab - .dropFirst() - .sink { tab in - Settings.selectedTabView = tab - } - .store(in: &cancellables) + @StateObject private var viewModel: MainViewModel - // Switch to POS view after location is selected - dittoService.$currentLocationId - .dropFirst() - .sink {[weak self] locationId in - guard let self = self, locationId != nil else { return } - selectedTab = .pos - } - .store(in: &cancellables) - - // Update main navbar title with current location name. - dittoService.$currentLocation - .receive(on: DispatchQueue.main) - .map { $0?.name ?? "Please Select Location" } - .assign(to: &$mainTitle) + init(locationsRepository: LocationsRepository) { + _viewModel = StateObject(wrappedValue: MainViewModel(locationsRepository: locationsRepository)) } -} -struct MainView: View { - @StateObject private var vm = MainVM() - @ObservedObject var dittoService = DittoService.shared - var body: some View { NavigationStack { - TabView(selection: $vm.selectedTab) { - POSView() - .tabItem { - Label("POS", systemImage: "dot.squareshape") - } - .tag(TabViews.pos) + TabView(selection: $viewModel.selectedTab) { + POSView( + ordersRepository: ordersRepository, + saleItemsRepository: saleItemsRepository, + locationsRepository: locationsRepository + ) + .tabItem { + Label("POS", systemImage: "dot.squareshape") + } + .tag(TabViews.pos) - KDSView() + KDSView(ordersRepository: ordersRepository) .tabItem { Label("KDS", systemImage: "square.grid.3x1.below.line.grid.1x2") } .tag(TabViews.kds) - LocationsView() + LocationsView(locationsRepository: locationsRepository) .tabItem { Label("Locations", systemImage: "globe") } .tag(TabViews.locations) } - .sheet(isPresented: $vm.presentSettingsView) { + .sheet(isPresented: $viewModel.presentSettingsView) { SettingsView() } .toolbarBackground(.visible, for: .navigationBar) .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading ) { + ToolbarItemGroup(placement: .navigationBarLeading) { Button { - vm.presentSettingsView = true + viewModel.presentSettingsView = true } label: { Image(systemName: "gearshape") } } } - .navigationTitle(vm.mainTitle) + .navigationTitle(viewModel.mainTitle) #if !os(tvOS) .navigationBarTitleDisplayMode(.inline) #endif .navigationViewStyle(StackNavigationViewStyle()) - .onAppear { - if dittoService.locationSetupNotValid { - dittoService.resetLocationSelection() - vm.selectedTab = .locations - } - } + .onAppear { viewModel.ensureLocationSelected() } } } - - var barTitle: String { - dittoService.currentLocation?.name ?? "Please Select Location" - } -} - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - MainView() - } } diff --git a/iOS/DittoPOS/MainViewModel.swift b/iOS/DittoPOS/MainViewModel.swift new file mode 100644 index 0000000..0614ecb --- /dev/null +++ b/iOS/DittoPOS/MainViewModel.swift @@ -0,0 +1,57 @@ +// +// MainViewModel.swift +// DittoPOS +// +// Copyright © 2026 DittoLive Incorporated. All rights reserved. +// + +import Combine +import Foundation + +/// Owns the root-view state: which tab is selected, whether the settings +/// sheet is presented, and the title shown in the nav bar. Subscribes to +/// `LocationsRepository` so picking a location auto-switches to the POS tab +/// and the title follows the active location. Mirrors Android's +/// `MainViewModel`. +@MainActor final class MainViewModel: ObservableObject { + + @Published var selectedTab: TabViews + @Published var presentSettingsView = false + @Published private(set) var mainTitle = "Please Select Location" + + private let locationsRepository: LocationsRepository + private var cancellables = Set() + + init(locationsRepository: LocationsRepository) { + self.locationsRepository = locationsRepository + selectedTab = locationsRepository.currentLocationId == nil + ? .locations + : (Settings.selectedTabView ?? .pos) + + // Persist tab selection. + $selectedTab + .dropFirst() + .sink { Settings.selectedTabView = $0 } + .store(in: &cancellables) + + // Auto-switch to POS once a location becomes active. + locationsRepository.$currentLocationId + .dropFirst() + .compactMap { $0 } + .sink { [weak self] _ in self?.selectedTab = .pos } + .store(in: &cancellables) + + // Title follows the active location. + locationsRepository.$currentLocation + .map { $0?.name ?? "Please Select Location" } + .assign(to: &$mainTitle) + } + + /// Called from `MainView.onAppear`. If no location is active, make sure + /// we're on the Locations tab so the user is prompted to pick one. + func ensureLocationSelected() { + if locationsRepository.currentLocationId == nil { + selectedTab = .locations + } + } +} diff --git a/iOS/DittoPOS/POS/POSGridView.swift b/iOS/DittoPOS/POS/POSGridView.swift index 92d7969..728abac 100644 --- a/iOS/DittoPOS/POS/POSGridView.swift +++ b/iOS/DittoPOS/POS/POSGridView.swift @@ -8,18 +8,18 @@ import SwiftUI struct POSGridView: View { - @Environment(\.horizontalSizeClass) private var HsizeClass - @ObservedObject var dataVM = POS_VM.shared - @State var columns = [GridItem]() + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @EnvironmentObject var viewModel: POSViewModel + @EnvironmentObject var layoutViewModel: POSLayoutViewModel var body: some View { NavigationView { ScrollView(showsIndicators: false) { #if os(tvOS) LazyVGrid(columns: [GridItem(), GridItem(), GridItem()], spacing: 10) { - ForEach(dataVM.saleItems, id: \.self) { item in + ForEach(viewModel.saleItems, id: \.self) { item in Button(action: { - dataVM.addOrderItem(item) + viewModel.addOrderItem(item) }, label: { VStack { Image(ImageNameMapping.assetName(for: item.imageName)) @@ -33,12 +33,13 @@ struct POSGridView: View { } } #else - LazyVGrid(columns: columns) { - ForEach(dataVM.saleItems, id: \.self) { item in - SaleItemView(item, length: itemSide) - .frame(width: itemSide, height: itemSide + 8) + LazyVGrid(columns: layoutViewModel.gridColumns(for: horizontalSizeClass)) { + ForEach(viewModel.saleItems, id: \.self) { item in + let side = layoutViewModel.itemSide(for: horizontalSizeClass) + SaleItemView(item, length: side) + .frame(width: side, height: side + 8) .onTapGesture { - dataVM.addOrderItem(item) + viewModel.addOrderItem(item) } } } @@ -46,20 +47,5 @@ struct POSGridView: View { #endif } } - .onAppear { columns = cols() } - } - - func cols() -> [GridItem] { - [GridItem(.adaptive(minimum: HsizeClass == .compact ? 100 : 160), alignment: .top)] - } - - var itemSide: CGFloat { - HsizeClass == .compact ? 100 : 160 - } -} - -struct POSGridView_Previews: PreviewProvider { - static var previews: some View { - POSGridView() } } diff --git a/iOS/DittoPOS/POS/POSOrderTotalView.swift b/iOS/DittoPOS/POS/POSOrderTotalView.swift index 5e07fbf..3ae980e 100644 --- a/iOS/DittoPOS/POS/POSOrderTotalView.swift +++ b/iOS/DittoPOS/POS/POSOrderTotalView.swift @@ -5,100 +5,44 @@ // Copyright © 2026 DittoLive Incorporated. All rights reserved. // -import Combine import SwiftUI -@MainActor class POSOrderTotalVM: ObservableObject { - @Published var orderIsPaid: Bool = false - @Published var orderIsEmpty: Bool - @Published var orderTotal: Price = Price(cents: 0) - private var cancellables = Set() - private var dataVM = POS_VM.shared - - init() { - let dvm = POS_VM.shared - if let curOrder = dvm.currentOrder { - self.orderIsEmpty = curOrder.cart.isEmpty - } else { - self.orderIsEmpty = true - } - - dataVM.$currentOrder - .sink {[weak self] order in - guard let self = self else { return } - guard let order = order else { return } - - orderTotal = order.total - if orderIsEmpty != order.cart.isEmpty { - orderIsEmpty.toggle() - } - if orderIsPaid != order.isPaid { - orderIsPaid.toggle() - } - } - .store(in: &cancellables) - } - - var disableButtons: Bool { - orderIsPaid || orderIsEmpty - } - - func payOrder() { - dataVM.payCurrentOrder() - } - - func cancelOrder() { - if let order = dataVM.currentOrder, !order.cart.isEmpty { - dataVM.clearCurrentOrderCart() - } - } -} - struct POSOrderTotalView: View { - @StateObject var vm = POSOrderTotalVM() + @EnvironmentObject var viewModel: POSViewModel var body: some View { VStack(spacing: 0) { HStack(alignment: .bottom, spacing: 0) { Text("Total") Spacer() - Text(vm.orderTotal.description) + Text(viewModel.orderTotalDisplay) } .scaledFont(size: 16) .padding(.vertical, 4) HStack { Button { - print("Cancel button tapped") - vm.cancelOrder() + viewModel.clearCurrentOrderCart() } label: { Text("X").font(.largeTitle) } .clipShape(Circle()) .tint(.red) - .disabled(vm.disableButtons) + .disabled(viewModel.actionsDisabled) Spacer() Button { - print("Pay button tapped") - vm.payOrder() + viewModel.payCurrentOrder() } label: { - Text(vm.orderIsPaid ? "Paid" : "Pay") + Text(viewModel.payButtonLabel) .frame(maxWidth: .infinity, maxHeight: 36.0) } .tint(.green) - .disabled(vm.disableButtons) + .disabled(viewModel.actionsDisabled) } .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle) } } } - -struct OrderTotalView_Previews: PreviewProvider { - static var previews: some View { - POSOrderTotalView() - .frame(width: .screenWidth * 0.8) - } -} diff --git a/iOS/DittoPOS/POS/POSOrderView.swift b/iOS/DittoPOS/POS/POSOrderView.swift index c311e07..dec4042 100644 --- a/iOS/DittoPOS/POS/POSOrderView.swift +++ b/iOS/DittoPOS/POS/POSOrderView.swift @@ -5,59 +5,34 @@ // Copyright © 2026 DittoLive Incorporated. All rights reserved. // -import Combine import SwiftUI -@MainActor class POSOrderVM: ObservableObject { - @ObservedObject var dataVM = POS_VM.shared - // (lineItemId, line item) pairs in display order - @Published var orderItems: [(id: String, item: CartLineItem)] = [] - @Published var barTitle = "Order #\(POS_VM.shared.currentOrder?.title ?? "...")" - var cancellables = Set() - - init() { - dataVM.$currentOrder - .sink {[weak self] order in - guard let self = self else { return } - if let order = order { - barTitle = "Order #\(order.title)" - orderItems = order.cart - .sorted { $0.value.createdAt < $1.value.createdAt } - .map { (id: $0.key, item: $0.value) } - } else { - barTitle = "Order #..." - } - } - .store(in: &cancellables) - } -} - struct POSOrderView: View { - @StateObject var vm = POSOrderVM() + @EnvironmentObject var viewModel: POSViewModel var body: some View { VStack(spacing: 0) { - Text(vm.barTitle) + Text(viewModel.orderTitle) .scaledFont(size: 16) .padding(.bottom, 8) divider() .padding(.bottom, 8) - ScrollViewReader { svr in + ScrollViewReader { proxy in ScrollView(showsIndicators: false) { Section { - ForEach(vm.orderItems, id: \.id) { entry in + ForEach(viewModel.orderItems, id: \.id) { entry in POSOrderItemView(lineItemId: entry.id, entry.item) divider() } - .onChange(of: vm.orderItems.count) { _ in - if let last = vm.orderItems.last?.id { - withAnimation { svr.scrollTo(last, anchor: .top) } + .onChange(of: viewModel.orderItems.count) { _ in + if let last = viewModel.orderItems.last?.id { + withAnimation { proxy.scrollTo(last, anchor: .top) } } } #if !os(tvOS) .onRotate { _ in - withAnimation { scrollToBottom(proxy: svr) } + withAnimation { scrollToBottom(proxy: proxy) } } #endif } @@ -71,27 +46,9 @@ struct POSOrderView: View { } func scrollToBottom(proxy: ScrollViewProxy) { - #if !os(tvOS) - let orientation = UIDevice.current.orientation - guard orientation.isLandscape || orientation.isPortrait else { return } - #endif - - if let last = vm.orderItems.last?.id { - #if os(tvOS) - let delay = 0.5 - #else - let delay = (UIScreen.isPortrait && UIDevice.current.orientation.isLandscape && vm.orderItems.count > 4) ? 0.5 : 0.0 - #endif - - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - withAnimation { proxy.scrollTo(last, anchor: .bottom) } - } + guard let config = viewModel.scrollToBottomConfig() else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + config.delay) { + withAnimation { proxy.scrollTo(config.id, anchor: .bottom) } } } } - -struct POSOrderView_Previews: PreviewProvider { - static var previews: some View { - POSOrderView() - } -} diff --git a/iOS/DittoPOS/POS/POSView.swift b/iOS/DittoPOS/POS/POSView.swift index 5554e5f..4b2346e 100644 --- a/iOS/DittoPOS/POS/POSView.swift +++ b/iOS/DittoPOS/POS/POSView.swift @@ -8,13 +8,13 @@ import SwiftUI -@MainActor class POSViewModel: ObservableObject { +@MainActor class POSLayoutViewModel: ObservableObject { @Published var menuViewWidth: CGFloat = 0.0 @Published var orderViewWidth: CGFloat = 0.0 init() { updateWidths() } - + func updateWidths() { menuViewWidth = .screenWidth * 0.56 #if os(tvOS) @@ -23,43 +23,55 @@ import SwiftUI orderViewWidth = .screenWidth * 0.40 #endif } + + /// Sale-item tile side length, sized to the current horizontal size class. + /// Compact (e.g. iPhone portrait) gets a smaller tile. + func itemSide(for sizeClass: UserInterfaceSizeClass?) -> CGFloat { + sizeClass == .compact ? 100 : 160 + } + + /// Grid column definition for the menu, derived from the tile size. + func gridColumns(for sizeClass: UserInterfaceSizeClass?) -> [GridItem] { + [GridItem(.adaptive(minimum: itemSide(for: sizeClass)), alignment: .top)] + } } -struct POSView: View { - @StateObject var vm = POSViewModel() - @ObservedObject var posVM = POS_VM.shared - +struct POSView: View { + @StateObject private var layoutViewModel = POSLayoutViewModel() + @StateObject private var viewModel: POSViewModel + + init( + ordersRepository: OrdersRepository, + saleItemsRepository: SaleItemsRepository, + locationsRepository: LocationsRepository + ) { + _viewModel = StateObject(wrappedValue: POSViewModel( + ordersRepository: ordersRepository, + saleItemsRepository: saleItemsRepository, + locationsRepository: locationsRepository + )) + } + var body: some View { HStack { POSGridView() - .frame(width: vm.menuViewWidth) - + .frame(width: layoutViewModel.menuViewWidth) + Divider() - + POSOrderView() .padding(8) - .frame(width: vm.orderViewWidth) + .frame(width: layoutViewModel.orderViewWidth) } - .alert( - "Please select a location before ordering", - isPresented: $posVM.presentSelectLocationAlert) { - Button("OK", role: .cancel) { - Settings.selectedTabView = nil - } - } -#if !os(tvOS) + .environmentObject(viewModel) + .environmentObject(layoutViewModel) + #if !os(tvOS) .onRotate { orient in guard orient.isLandscape || orient.isPortrait else { return } DispatchQueue.main.async { - vm.updateWidths() + layoutViewModel.updateWidths() } } -#endif - } -} - -struct POSView_Previews: PreviewProvider { - static var previews: some View { - POSView() + #endif } } diff --git a/iOS/DittoPOS/POS/POSViewModel.swift b/iOS/DittoPOS/POS/POSViewModel.swift new file mode 100644 index 0000000..b511bc7 --- /dev/null +++ b/iOS/DittoPOS/POS/POSViewModel.swift @@ -0,0 +1,161 @@ +// +// POSViewModel.swift +// DittoPOS +// +// Copyright © 2026 DittoLive Incorporated. All rights reserved. +// + +import Combine +import DittoSwift +import SwiftUI + +@MainActor class POSViewModel: ObservableObject { + + @Published private(set) var currentOrder: Order? + @Published private(set) var saleItems: [SaleItem] = [] + + // MARK: Derived UI state + + var orderTitle: String { + "Order #\(currentOrder?.title ?? "...")" + } + + var orderItems: [(id: String, item: CartLineItem)] { + guard let order = currentOrder else { return [] } + return order.cart + .sorted { $0.value.createdAt < $1.value.createdAt } + .map { (id: $0.key, item: $0.value) } + } + + var orderTotalDisplay: String { (currentOrder?.total ?? Price(cents: 0)).description } + var orderIsPaid: Bool { currentOrder?.isPaid ?? false } + var orderIsEmpty: Bool { currentOrder?.cart.isEmpty ?? true } + var actionsDisabled: Bool { orderIsPaid || orderIsEmpty } + var payButtonLabel: String { orderIsPaid ? "Paid" : "Pay" } + + /// What the order list needs to auto-scroll to the bottom: the id of the + /// trailing line item and the animation delay (the delay only matters + /// during landscape rotation when the keyboard is settling). Returns nil + /// when the device is in a transitional orientation — in that case the + /// view should skip the scroll. + func scrollToBottomConfig() -> (id: String, delay: TimeInterval)? { + #if !os(tvOS) + let orientation = UIDevice.current.orientation + guard orientation.isLandscape || orientation.isPortrait else { return nil } + #endif + + guard let last = orderItems.last?.id else { return nil } + + #if os(tvOS) + return (last, 0.5) + #else + let needsDelay = UIScreen.isPortrait + && UIDevice.current.orientation.isLandscape + && orderItems.count > 4 + return (last, needsDelay ? 0.5 : 0.0) + #endif + } + + private let ordersRepository: OrdersRepository + private let saleItemsRepository: SaleItemsRepository + private let locationsRepository: LocationsRepository + private var cancellables = Set() + + init( + ordersRepository: OrdersRepository, + saleItemsRepository: SaleItemsRepository, + locationsRepository: LocationsRepository + ) { + self.ordersRepository = ordersRepository + self.saleItemsRepository = saleItemsRepository + self.locationsRepository = locationsRepository + + // Menu items follow the active location. + saleItemsRepository.$locationSaleItems + .receive(on: DispatchQueue.main) + .assign(to: &$saleItems) + + // On location change: reset any unpaid order left at the previous + // location, then start a fresh order at the new one (or clear when + // cleared). `scan` pairs consecutive emissions so we can read both + // the outgoing and incoming location in one place. + locationsRepository.$currentLocation + .scan((nil, nil)) { acc, next -> (Location?, Location?) in + (acc.1, next) + } + .receive(on: DispatchQueue.main) + .sink { [weak self] previous, current in + guard let self else { return } + + if let previous, + let outgoing = currentOrder, + !outgoing.isPaid, + outgoing.documentId.locationId == previous.id, + previous.id != current?.id { + ordersRepository.reset(order: outgoing) + } + + guard let current else { + currentOrder = nil + return + } + + if let order = currentOrder, order.documentId.locationId == current.id, !order.isPaid { + return + } + + startNewOrder(for: current.id) + } + .store(in: &cancellables) + + // Re-bind the current order to the synced version on every emission. + // Only assign when we find a match — right after `startNewOrder` + // writes a doc, the observer hasn't round-tripped yet and the new id + // isn't in `locationOrders`, so a nil assignment would clobber it. + ordersRepository.$locationOrders + .receive(on: DispatchQueue.main) + .sink { [weak self] orders in + guard let self, + let orderId = currentOrder?.documentId.id, + let locationId = locationsRepository.currentLocationId, + let match = orders.first(where: { + $0.documentId.id == orderId && $0.documentId.locationId == locationId + }) + else { return } + currentOrder = match + } + .store(in: &cancellables) + } + + func addOrderItem(_ saleItem: SaleItem) { + guard let order = currentOrder else { return } + ordersRepository.add( + item: CartLineItem(from: saleItem), + lineItemId: CartLineItem.newLineItemId(), + to: order + ) + } + + func payCurrentOrder() { + guard let order = currentOrder else { return } + let payment = Payment(type: .cash, amount: order.total, status: .complete) + ordersRepository.addPayment(payment, paymentId: Payment.newPaymentId(), to: order) + + // Brief pause so the paid state is visible, then start a fresh order. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self, let locationId = locationsRepository.currentLocationId else { return } + startNewOrder(for: locationId) + } + } + + func clearCurrentOrderCart() { + guard let order = currentOrder else { return } + ordersRepository.clearCart(of: order) + } + + private func startNewOrder(for locationId: String) { + let order = Order.new(locationId: locationId) + currentOrder = order + ordersRepository.add(order: order) + } +} diff --git a/iOS/DittoPOS/POS/POS_VM.swift b/iOS/DittoPOS/POS/POS_VM.swift deleted file mode 100644 index fdcbebc..0000000 --- a/iOS/DittoPOS/POS/POS_VM.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// POS_VM.swift -// DittoPOS -// -// Copyright © 2026 DittoLive Incorporated. All rights reserved. -// - -import Combine -import DittoSwift -import SwiftUI - -@MainActor class POS_VM: ObservableObject { - static var shared = POS_VM() - - @Published private(set) var currentOrder: Order? - @Published private(set) var saleItems: [SaleItem] = [] - @Published var presentSelectLocationAlert = false - private let dittoService = DittoService.shared - private var cancellables = Set() - - private init() { - // Sale items come from the synced `sale_items` collection, filtered - // to the current location. - dittoService.$locationSaleItems - .receive(on: DispatchQueue.main) - .assign(to: \.saleItems, on: self) - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: .willUpdateToLocationId) - .sink {[weak self] _ in - guard let self = self else { return } - guard let outgoingCurrentOrder = currentOrder, - !outgoingCurrentOrder.isPaid, - let _ = dittoService.currentLocation else { - return - } - dittoService.reset(order: outgoingCurrentOrder) - } - .store(in: &cancellables) - - dittoService.$currentLocation - .receive(on: DispatchQueue.main) - .sink {[weak self] location in - guard let location = location, let self = self else { - self?.currentOrder = nil - return - } - - if let order = currentOrder, order.documentId.locationId == location.id && !order.isPaid { - return - } - - startNewOrder(for: location.id) - } - .store(in: &cancellables) - - // Refresh the local copy of the current order whenever the synced - // collection changes. The synced version may have updates from another - // device, so we look up our order by id and re-bind to it. Only assign - // when we find a match — right after startNewOrder writes a doc, the - // observer hasn't round-tripped yet and the new id isn't in `orders`, - // so a nil assignment here would clobber the freshly-created order. - dittoService.$locationOrders - .receive(on: DispatchQueue.main) - .sink { [weak self] orders in - guard let self, - let orderId = currentOrder?.documentId.id, - let locationId = dittoService.currentLocationId, - let match = orders.first(where: { - $0.documentId.id == orderId && $0.documentId.locationId == locationId - }) - else { return } - currentOrder = match - } - .store(in: &cancellables) - } - - func addOrderItem(_ saleItem: SaleItem) { - guard let order = currentOrder else { return } - dittoService.add( - item: CartLineItem(from: saleItem), - lineItemId: CartLineItem.newLineItemId(), - to: order - ) - } - - func payCurrentOrder() { - guard let order = currentOrder else { return } - let payment = Payment(type: .cash, amount: order.total, status: .complete) - dittoService.addPayment(payment, paymentId: Payment.newPaymentId(), to: order) - - // Brief pause so the paid state is visible, then start a fresh order. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - guard let self, let locationId = dittoService.currentLocationId else { return } - startNewOrder(for: locationId) - } - } - - func clearCurrentOrderCart() { - guard let order = currentOrder else { return } - dittoService.clearCart(of: order) - } - - private func startNewOrder(for locationId: String) { - let order = Order.new(locationId: locationId) - currentOrder = order - dittoService.add(order: order) - } -} diff --git a/iOS/DittoPOS/Settings/AdvancedSettings.swift b/iOS/DittoPOS/Settings/AdvancedSettings.swift index 4e26827..637d5cb 100644 --- a/iOS/DittoPOS/Settings/AdvancedSettings.swift +++ b/iOS/DittoPOS/Settings/AdvancedSettings.swift @@ -8,10 +8,12 @@ import SwiftUI struct AdvancedSettings: View { + @EnvironmentObject var locationsRepository: LocationsRepository + var body: some View { List { Section { - if let locName = Settings.locationId { + if let locName = locationsRepository.currentLocationId { Text("Current location: \"\(locName)\"") } else { Text("No location selected") @@ -20,7 +22,7 @@ struct AdvancedSettings: View { Section { Button("Reset Location") { - DittoService.shared.resetLocationSelection() + locationsRepository.setActiveLocation(nil) } } } diff --git a/iOS/DittoPOS/Settings/SettingsView.swift b/iOS/DittoPOS/Settings/SettingsView.swift index 9fdd302..36efc92 100644 --- a/iOS/DittoPOS/Settings/SettingsView.swift +++ b/iOS/DittoPOS/Settings/SettingsView.swift @@ -10,7 +10,7 @@ import DittoSwift import SwiftUI struct SettingsView: View { - private let ditto = DittoService.shared.ditto + private let ditto = DittoManager.shared.ditto var body: some View { NavigationView { diff --git a/iOS/DittoPOS/Utilities/CurrencyUtils.swift b/iOS/DittoPOS/Utilities/CurrencyUtils.swift index e0b6a17..8143dfa 100644 --- a/iOS/DittoPOS/Utilities/CurrencyUtils.swift +++ b/iOS/DittoPOS/Utilities/CurrencyUtils.swift @@ -36,10 +36,10 @@ extension Price { extension Price: CustomStringConvertible { static var formatter: NumberFormatter { - let f = NumberFormatter() - f.numberStyle = .currency - f.maximumFractionDigits = 2 - return f + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.maximumFractionDigits = 2 + return formatter } var description: String { diff --git a/iOS/DittoPOS/Utilities/Fixtures.swift b/iOS/DittoPOS/Utilities/Fixtures.swift index a215247..657ba90 100644 --- a/iOS/DittoPOS/Utilities/Fixtures.swift +++ b/iOS/DittoPOS/Utilities/Fixtures.swift @@ -9,10 +9,9 @@ import Foundation // Preview-only fixtures. Seed data lives in Data/Demo/. enum Fixtures { - static let kdsVM = KDS_VM(previewOrders: [order1, order1]) + static let kdsVM = KDSViewModel(ordersRepository: OrdersRepository(), previewOrders: [order1, order1]) static let date = Date.now - static let createdAtStr = DittoWireDate.string(from: date) static let statusEntry = StatusLogDerivation.entry(.inProcess, at: date) static let salesItem1 = SaleItem.seed(id: "00001", locationId: "Test lab-Denver", name: "Burger", imageName: "burger", cents: 850) diff --git a/iOS/DittoPOS/Utilities/Utils.swift b/iOS/DittoPOS/Utilities/Utils.swift index 9ca8174..209bd29 100644 --- a/iOS/DittoPOS/Utilities/Utils.swift +++ b/iOS/DittoPOS/Utilities/Utils.swift @@ -85,10 +85,6 @@ extension UIDeviceOrientation: CustomStringConvertible { } #endif -extension NSNotification.Name { - static let willUpdateToLocationId = Notification.Name("willUpdateToLocationId") -} - // https://www.swiftbysundell.com/articles/reducers-in-swift/ extension Sequence { func sum(_ keyPath: KeyPath) -> T { @@ -100,9 +96,9 @@ extension Sequence { extension DateFormatter { static var shortTime: DateFormatter { - let f = DateFormatter() - f.timeStyle = .short - return f + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter } /// Local midnight (start of today) in the canonical Ditto wire format.