Add real-time inventory syncing across devices v1.1.0
🔄 Inventory Sync Features: - Automatic sync to Firebase when adding/updating/deleting items - Real-time listener pulls changes from other devices - Bi-directional sync keeps all household devices in sync - Conflict resolution based on lastModified timestamp - Firebase version always wins on conflicts 📱 How It Works: Device A adds item → syncs to Firebase → Device B receives update Device B updates item → syncs to Firebase → Device A receives update Device A deletes item → syncs to Firebase → Device B removes item 🔧 Technical Implementation: - InventorySyncService: Real-time Firestore listener - Repository hooks: add/update/delete sync to Firebase - HomeScreen lifecycle: starts/stops sync automatically - Conflict resolution: newer timestamp wins - Local Hive + Cloud Firestore hybrid architecture 📁 New Files: - lib/features/household/services/inventory_sync_service.dart ✨ Updated Files: - lib/features/inventory/repositories/inventory_repository_impl.dart - Added Firebase sync on add/update/delete operations - Maintains local Hive for offline access - lib/features/home/screens/home_screen.dart - Starts sync service on init if in household - Stops sync service on dispose ⚠️ Requirements: - Firebase must be configured (see FIREBASE_SETUP.md) - Internet connection required for cross-device sync - Local Hive works offline, syncs when online ✅ Build Status: - APK: 63.4MB - Package: com.github.mystiatech.sage - Version: 1.1.0+2 🎯 Next Steps for User: 1. Set up Firebase (FIREBASE_SETUP.md) 2. Replace google-services.json with real file 3. Rebuild APK 4. Install on both devices 5. Create/join household 6. Add items → they sync! 🎉 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"project_info": {
|
"project_info": {
|
||||||
"project_number": "PLACEHOLDER",
|
"project_number": "41823683095",
|
||||||
"project_id": "sage-kitchen-management",
|
"project_id": "sage-kitchen-management",
|
||||||
"storage_bucket": "sage-kitchen-management.appspot.com"
|
"storage_bucket": "sage-kitchen-management.firebasestorage.app"
|
||||||
},
|
},
|
||||||
"client": [
|
"client": [
|
||||||
{
|
{
|
||||||
"client_info": {
|
"client_info": {
|
||||||
"mobilesdk_app_id": "1:PLACEHOLDER:android:PLACEHOLDER",
|
"mobilesdk_app_id": "1:41823683095:android:be7f05a025091b77eed252",
|
||||||
"android_client_info": {
|
"android_client_info": {
|
||||||
"package_name": "com.github.mystiatech.sage"
|
"package_name": "com.github.mystiatech.sage"
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"oauth_client": [],
|
"oauth_client": [],
|
||||||
"api_key": [
|
"api_key": [
|
||||||
{
|
{
|
||||||
"current_key": "PLACEHOLDER_API_KEY"
|
"current_key": "AIzaSyCh96OkpduplIxBDc5_-MFq5bgIjvKW3AE"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"services": {
|
"services": {
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/constants/colors.dart';
|
import '../../../core/constants/colors.dart';
|
||||||
|
import '../../../data/local/hive_database.dart';
|
||||||
|
import '../../household/services/inventory_sync_service.dart';
|
||||||
import '../../inventory/controllers/inventory_controller.dart';
|
import '../../inventory/controllers/inventory_controller.dart';
|
||||||
import '../../inventory/screens/add_item_screen.dart';
|
import '../../inventory/screens/add_item_screen.dart';
|
||||||
import '../../inventory/screens/barcode_scanner_screen.dart';
|
import '../../inventory/screens/barcode_scanner_screen.dart';
|
||||||
@@ -8,11 +10,42 @@ import '../../inventory/screens/inventory_screen.dart';
|
|||||||
import '../../settings/screens/settings_screen.dart';
|
import '../../settings/screens/settings_screen.dart';
|
||||||
|
|
||||||
/// Home screen - Dashboard with expiring items and quick actions
|
/// Home screen - Dashboard with expiring items and quick actions
|
||||||
class HomeScreen extends ConsumerWidget {
|
class HomeScreen extends ConsumerStatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<HomeScreen> createState() => _HomeScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||||
|
final _syncService = InventorySyncService();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_startSyncIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_syncService.stopSync();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startSyncIfNeeded() async {
|
||||||
|
final settings = await HiveDatabase.getSettings();
|
||||||
|
if (settings.currentHouseholdId != null) {
|
||||||
|
try {
|
||||||
|
await _syncService.startSync(settings.currentHouseholdId!);
|
||||||
|
print('🔄 Started syncing inventory for household: ${settings.currentHouseholdId}');
|
||||||
|
} catch (e) {
|
||||||
|
print('Failed to start sync: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final itemCount = ref.watch(itemCountProvider);
|
final itemCount = ref.watch(itemCountProvider);
|
||||||
final expiringSoon = ref.watch(expiringSoonProvider);
|
final expiringSoon = ref.watch(expiringSoonProvider);
|
||||||
|
|
||||||
|
102
lib/features/household/services/inventory_sync_service.dart
Normal file
102
lib/features/household/services/inventory_sync_service.dart
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import '../../../data/local/hive_database.dart';
|
||||||
|
import '../../inventory/models/food_item.dart';
|
||||||
|
|
||||||
|
/// Service for syncing inventory items with Firebase in real-time
|
||||||
|
class InventorySyncService {
|
||||||
|
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||||
|
StreamSubscription? _itemsSubscription;
|
||||||
|
|
||||||
|
/// Start listening to household items from Firebase
|
||||||
|
Future<void> startSync(String householdId) async {
|
||||||
|
await stopSync(); // Stop any existing subscription
|
||||||
|
|
||||||
|
_itemsSubscription = _firestore
|
||||||
|
.collection('households')
|
||||||
|
.doc(householdId)
|
||||||
|
.collection('items')
|
||||||
|
.snapshots()
|
||||||
|
.listen((snapshot) async {
|
||||||
|
await _handleItemsUpdate(snapshot, householdId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop listening to Firebase updates
|
||||||
|
Future<void> stopSync() async {
|
||||||
|
await _itemsSubscription?.cancel();
|
||||||
|
_itemsSubscription = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle updates from Firebase
|
||||||
|
Future<void> _handleItemsUpdate(
|
||||||
|
QuerySnapshot snapshot,
|
||||||
|
String householdId,
|
||||||
|
) async {
|
||||||
|
final box = await HiveDatabase.getFoodBox();
|
||||||
|
|
||||||
|
// Track Firebase item IDs
|
||||||
|
final firebaseItemIds = <String>{};
|
||||||
|
|
||||||
|
for (final doc in snapshot.docs) {
|
||||||
|
firebaseItemIds.add(doc.id);
|
||||||
|
final data = doc.data() as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Check if item exists in local Hive
|
||||||
|
final itemKey = int.tryParse(doc.id);
|
||||||
|
if (itemKey != null) {
|
||||||
|
final existingItem = box.get(itemKey);
|
||||||
|
|
||||||
|
// Create or update item
|
||||||
|
final item = _createFoodItemFromData(data, householdId);
|
||||||
|
|
||||||
|
if (existingItem == null) {
|
||||||
|
// New item from Firebase - add to local Hive with specific key
|
||||||
|
await box.put(itemKey, item);
|
||||||
|
} else {
|
||||||
|
// Update existing item if Firebase version is newer
|
||||||
|
final firebaseModified = DateTime.parse(data['lastModified'] as String);
|
||||||
|
final localModified = existingItem.lastModified ?? DateTime(2000);
|
||||||
|
|
||||||
|
if (firebaseModified.isAfter(localModified)) {
|
||||||
|
// Firebase version is newer - update local
|
||||||
|
await box.put(itemKey, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete items that no longer exist in Firebase
|
||||||
|
final itemsToDelete = <int>[];
|
||||||
|
for (final item in box.values) {
|
||||||
|
if (item.householdId == householdId && item.key != null) {
|
||||||
|
if (!firebaseItemIds.contains(item.key.toString())) {
|
||||||
|
itemsToDelete.add(item.key!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final key in itemsToDelete) {
|
||||||
|
await box.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create FoodItem from Firebase data
|
||||||
|
FoodItem _createFoodItemFromData(Map<String, dynamic> data, String householdId) {
|
||||||
|
return FoodItem()
|
||||||
|
..name = data['name'] as String
|
||||||
|
..barcode = data['barcode'] as String?
|
||||||
|
..quantity = data['quantity'] as int
|
||||||
|
..unit = data['unit'] as String?
|
||||||
|
..purchaseDate = DateTime.parse(data['purchaseDate'] as String)
|
||||||
|
..expirationDate = DateTime.parse(data['expirationDate'] as String)
|
||||||
|
..locationIndex = data['locationIndex'] as int
|
||||||
|
..category = data['category'] as String?
|
||||||
|
..photoUrl = data['photoUrl'] as String?
|
||||||
|
..notes = data['notes'] as String?
|
||||||
|
..userId = data['userId'] as String?
|
||||||
|
..householdId = householdId
|
||||||
|
..lastModified = DateTime.parse(data['lastModified'] as String)
|
||||||
|
..syncedToCloud = true;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,11 +1,13 @@
|
|||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import '../../../data/local/hive_database.dart';
|
import '../../../data/local/hive_database.dart';
|
||||||
import '../../settings/models/app_settings.dart';
|
import '../../settings/models/app_settings.dart';
|
||||||
|
import '../../household/services/firebase_household_service.dart';
|
||||||
import '../models/food_item.dart';
|
import '../models/food_item.dart';
|
||||||
import 'inventory_repository.dart';
|
import 'inventory_repository.dart';
|
||||||
|
|
||||||
/// Hive implementation of InventoryRepository
|
/// Hive implementation of InventoryRepository with Firebase sync
|
||||||
class InventoryRepositoryImpl implements InventoryRepository {
|
class InventoryRepositoryImpl implements InventoryRepository {
|
||||||
|
final _firebaseService = FirebaseHouseholdService();
|
||||||
Future<Box<FoodItem>> get _box async => await HiveDatabase.getFoodBox();
|
Future<Box<FoodItem>> get _box async => await HiveDatabase.getFoodBox();
|
||||||
|
|
||||||
/// Get the current household ID from settings
|
/// Get the current household ID from settings
|
||||||
@@ -47,17 +49,57 @@ class InventoryRepositoryImpl implements InventoryRepository {
|
|||||||
final box = await _box;
|
final box = await _box;
|
||||||
item.lastModified = DateTime.now();
|
item.lastModified = DateTime.now();
|
||||||
await box.add(item);
|
await box.add(item);
|
||||||
|
|
||||||
|
// Sync to Firebase if in a household
|
||||||
|
if (item.householdId != null && item.key != null) {
|
||||||
|
try {
|
||||||
|
await _firebaseService.addFoodItem(
|
||||||
|
item.householdId!,
|
||||||
|
item,
|
||||||
|
item.key.toString(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
print('Failed to sync item to Firebase: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> updateItem(FoodItem item) async {
|
Future<void> updateItem(FoodItem item) async {
|
||||||
item.lastModified = DateTime.now();
|
item.lastModified = DateTime.now();
|
||||||
await item.save();
|
await item.save();
|
||||||
|
|
||||||
|
// Sync to Firebase if in a household
|
||||||
|
if (item.householdId != null && item.key != null) {
|
||||||
|
try {
|
||||||
|
await _firebaseService.updateFoodItem(
|
||||||
|
item.householdId!,
|
||||||
|
item,
|
||||||
|
item.key.toString(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
print('Failed to sync item update to Firebase: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteItem(int id) async {
|
Future<void> deleteItem(int id) async {
|
||||||
final box = await _box;
|
final box = await _box;
|
||||||
|
final item = box.get(id);
|
||||||
|
|
||||||
|
// Sync deletion to Firebase if in a household
|
||||||
|
if (item != null && item.householdId != null) {
|
||||||
|
try {
|
||||||
|
await _firebaseService.deleteFoodItem(
|
||||||
|
item.householdId!,
|
||||||
|
id.toString(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
print('Failed to sync item deletion to Firebase: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await box.delete(id);
|
await box.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user