v1.3.0+4: FOSS Compliance + Dark Mode + Enhanced Settings
✨ Major Features: - Dark mode toggle with app-wide theme switching - Sort inventory by Expiration Date, Name, or Location - Toggle between Grid and List view for inventory - Export inventory data to CSV with share functionality - Custom sage leaf app icon with adaptive icon support 🔄 FOSS Compliance (F-Droid Ready): - Replaced Firebase with Supabase (open-source backend) - Anonymous authentication (no user accounts required) - Cloud-first with hosted Supabase as default - Optional self-hosting support - 100% FOSS-compliant dependencies 🎨 UI/UX Improvements: - Dynamic version display from package.json (was hardcoded) - Added edit buttons for household and user names - Removed non-functional search button - Replaced Recipes placeholder with Settings button - Improved settings organization with clear sections 📦 Dependencies: Added: - supabase_flutter: ^2.8.4 (FOSS backend sync) - package_info_plus: ^8.1.0 (dynamic version) - csv: ^6.0.0 (data export) - share_plus: ^10.1.2 (file sharing) - image: ^4.5.4 (dev, icon generation) Removed: - firebase_core (replaced with Supabase) - cloud_firestore (replaced with Supabase) 🗑️ Cleanup: - Removed Firebase setup files and google-services.json - Removed unimplemented features (Recipes, Search) - Removed firebase_household_service.dart - Removed inventory_sync_service.dart (replaced with Supabase) 📄 New Files: - lib/features/household/services/supabase_household_service.dart - web/privacy-policy.html (Play Store requirement) - web/terms-of-service.html (Play Store requirement) - PLAY_STORE_LISTING.md (marketing copy) - tool/generate_icons.dart (icon generation script) - assets/icon/sage_leaf.png (1024x1024) - assets/icon/sage_leaf_foreground.png (adaptive icon) 🐛 Bug Fixes: - Fixed version display showing hardcoded "1.0.0" - Fixed Sort By and Default View showing static text - Fixed ConsumerWidget build signatures - Fixed Location.displayName import issues - Added clearAllData method to Hive database 📊 Stats: +1,728 additions, -756 deletions across 42 files 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,199 +0,0 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import '../../settings/models/household.dart';
|
||||
import '../../../features/inventory/models/food_item.dart';
|
||||
|
||||
/// Service for managing household data in Firestore
|
||||
class FirebaseHouseholdService {
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
|
||||
/// Create a new household in Firestore
|
||||
Future<Household> createHousehold(String name, String ownerName) async {
|
||||
final household = Household(
|
||||
id: Household.generateCode(),
|
||||
name: name,
|
||||
ownerName: ownerName,
|
||||
createdAt: DateTime.now(),
|
||||
members: [ownerName],
|
||||
);
|
||||
|
||||
await _firestore.collection('households').doc(household.id).set({
|
||||
'id': household.id,
|
||||
'name': household.name,
|
||||
'ownerName': household.ownerName,
|
||||
'createdAt': household.createdAt.toIso8601String(),
|
||||
'members': household.members,
|
||||
});
|
||||
|
||||
return household;
|
||||
}
|
||||
|
||||
/// Get household by code from Firestore
|
||||
Future<Household?> getHousehold(String code) async {
|
||||
try {
|
||||
final doc = await _firestore.collection('households').doc(code).get();
|
||||
|
||||
if (!doc.exists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final data = doc.data()!;
|
||||
final household = Household(
|
||||
id: data['id'] as String,
|
||||
name: data['name'] as String,
|
||||
ownerName: data['ownerName'] as String,
|
||||
createdAt: DateTime.parse(data['createdAt'] as String),
|
||||
members: List<String>.from(data['members'] as List),
|
||||
);
|
||||
|
||||
return household;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Join a household (add member)
|
||||
Future<bool> joinHousehold(String code, String memberName) async {
|
||||
try {
|
||||
final docRef = _firestore.collection('households').doc(code);
|
||||
final doc = await docRef.get();
|
||||
|
||||
if (!doc.exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final members = List<String>.from(doc.data()!['members'] as List);
|
||||
if (!members.contains(memberName)) {
|
||||
members.add(memberName);
|
||||
await docRef.update({'members': members});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave a household (remove member)
|
||||
Future<void> leaveHousehold(String code, String memberName) async {
|
||||
final docRef = _firestore.collection('households').doc(code);
|
||||
final doc = await docRef.get();
|
||||
|
||||
if (doc.exists) {
|
||||
final members = List<String>.from(doc.data()!['members'] as List);
|
||||
members.remove(memberName);
|
||||
|
||||
if (members.isEmpty) {
|
||||
// Delete household if no members left
|
||||
await docRef.delete();
|
||||
} else {
|
||||
await docRef.update({'members': members});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add food item to household in Firestore
|
||||
Future<void> addFoodItem(String householdId, FoodItem item, String itemKey) async {
|
||||
await _firestore
|
||||
.collection('households')
|
||||
.doc(householdId)
|
||||
.collection('items')
|
||||
.doc(itemKey.toString())
|
||||
.set({
|
||||
'name': item.name,
|
||||
'barcode': item.barcode,
|
||||
'quantity': item.quantity,
|
||||
'unit': item.unit,
|
||||
'purchaseDate': item.purchaseDate.toIso8601String(),
|
||||
'expirationDate': item.expirationDate.toIso8601String(),
|
||||
'locationIndex': item.locationIndex,
|
||||
'category': item.category,
|
||||
'photoUrl': item.photoUrl,
|
||||
'notes': item.notes,
|
||||
'userId': item.userId,
|
||||
'householdId': item.householdId,
|
||||
'lastModified': item.lastModified?.toIso8601String(),
|
||||
'syncedToCloud': true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Update food item in Firestore
|
||||
Future<void> updateFoodItem(String householdId, FoodItem item, String itemKey) async {
|
||||
await _firestore
|
||||
.collection('households')
|
||||
.doc(householdId)
|
||||
.collection('items')
|
||||
.doc(itemKey.toString())
|
||||
.update({
|
||||
'name': item.name,
|
||||
'barcode': item.barcode,
|
||||
'quantity': item.quantity,
|
||||
'unit': item.unit,
|
||||
'purchaseDate': item.purchaseDate.toIso8601String(),
|
||||
'expirationDate': item.expirationDate.toIso8601String(),
|
||||
'locationIndex': item.locationIndex,
|
||||
'category': item.category,
|
||||
'photoUrl': item.photoUrl,
|
||||
'notes': item.notes,
|
||||
'lastModified': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Delete food item from Firestore
|
||||
Future<void> deleteFoodItem(String householdId, String itemKey) async {
|
||||
await _firestore
|
||||
.collection('households')
|
||||
.doc(householdId)
|
||||
.collection('items')
|
||||
.doc(itemKey.toString())
|
||||
.delete();
|
||||
}
|
||||
|
||||
/// Stream household items from Firestore
|
||||
Stream<List<Map<String, dynamic>>> streamHouseholdItems(String householdId) {
|
||||
return _firestore
|
||||
.collection('households')
|
||||
.doc(householdId)
|
||||
.collection('items')
|
||||
.snapshots()
|
||||
.map((snapshot) {
|
||||
return snapshot.docs.map((doc) {
|
||||
final data = doc.data();
|
||||
data['firestoreId'] = doc.id;
|
||||
return data;
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
/// Sync local items to Firestore
|
||||
Future<void> syncItemsToFirestore(String householdId, List<FoodItem> items) async {
|
||||
final batch = _firestore.batch();
|
||||
final collection = _firestore
|
||||
.collection('households')
|
||||
.doc(householdId)
|
||||
.collection('items');
|
||||
|
||||
for (final item in items) {
|
||||
if (item.householdId == householdId && item.key != null) {
|
||||
final docRef = collection.doc(item.key.toString());
|
||||
batch.set(docRef, {
|
||||
'name': item.name,
|
||||
'barcode': item.barcode,
|
||||
'quantity': item.quantity,
|
||||
'unit': item.unit,
|
||||
'purchaseDate': item.purchaseDate.toIso8601String(),
|
||||
'expirationDate': item.expirationDate.toIso8601String(),
|
||||
'locationIndex': item.locationIndex,
|
||||
'category': item.category,
|
||||
'photoUrl': item.photoUrl,
|
||||
'notes': item.notes,
|
||||
'userId': item.userId,
|
||||
'householdId': item.householdId,
|
||||
'lastModified': item.lastModified?.toIso8601String(),
|
||||
'syncedToCloud': true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
}
|
@@ -1,136 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter/foundation.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;
|
||||
final _syncCallbacks = <VoidCallback>[];
|
||||
|
||||
/// Register a callback to be called when sync occurs
|
||||
void addSyncCallback(VoidCallback callback) {
|
||||
_syncCallbacks.add(callback);
|
||||
}
|
||||
|
||||
/// Remove a sync callback
|
||||
void removeSyncCallback(VoidCallback callback) {
|
||||
_syncCallbacks.remove(callback);
|
||||
}
|
||||
|
||||
/// Start listening to household items from Firebase
|
||||
Future<void> startSync(String householdId) async {
|
||||
await stopSync(); // Stop any existing subscription
|
||||
|
||||
print('📡 Starting Firebase sync for household: $householdId');
|
||||
|
||||
_itemsSubscription = _firestore
|
||||
.collection('households')
|
||||
.doc(householdId)
|
||||
.collection('items')
|
||||
.snapshots()
|
||||
.listen((snapshot) async {
|
||||
print('🔄 Received ${snapshot.docs.length} items from Firebase');
|
||||
await _handleItemsUpdate(snapshot, householdId);
|
||||
|
||||
// Notify listeners
|
||||
for (final callback in _syncCallbacks) {
|
||||
callback();
|
||||
}
|
||||
}, onError: (error) {
|
||||
print('❌ Firebase sync error: $error');
|
||||
});
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
print('📦 Processing ${snapshot.docs.length} items from Firebase');
|
||||
final box = await HiveDatabase.getFoodBox();
|
||||
|
||||
// Track Firebase item IDs
|
||||
final firebaseItemIds = <String>{};
|
||||
int newItems = 0;
|
||||
int updatedItems = 0;
|
||||
|
||||
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);
|
||||
newItems++;
|
||||
print('➕ Added new item from Firebase: ${item.name} (key: $itemKey)');
|
||||
} 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);
|
||||
updatedItems++;
|
||||
print('🔄 Updated item from Firebase: ${item.name} (key: $itemKey)');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print('📊 Sync stats: $newItems new, $updatedItems updated');
|
||||
|
||||
// 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!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsToDelete.isNotEmpty) {
|
||||
print('🗑️ Deleting ${itemsToDelete.length} items that no longer exist in Firebase');
|
||||
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;
|
||||
}
|
||||
}
|
227
lib/features/household/services/supabase_household_service.dart
Normal file
227
lib/features/household/services/supabase_household_service.dart
Normal file
@@ -0,0 +1,227 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../../settings/models/household.dart';
|
||||
import '../../../features/inventory/models/food_item.dart';
|
||||
|
||||
/// FOSS-compliant household sync using Supabase (open source Firebase alternative)
|
||||
/// Users can use free Supabase cloud tier OR self-host their own instance!
|
||||
class SupabaseHouseholdService {
|
||||
final SupabaseClient _client = Supabase.instance.client;
|
||||
|
||||
/// Check if user is authenticated with Supabase
|
||||
bool get isAuthenticated => _client.auth.currentUser != null;
|
||||
|
||||
/// Create a new household in Supabase
|
||||
Future<Household> createHousehold(String name, String ownerName) async {
|
||||
// Ensure we're signed in anonymously
|
||||
await signInAnonymously();
|
||||
|
||||
final household = Household(
|
||||
id: Household.generateCode(),
|
||||
name: name,
|
||||
ownerName: ownerName,
|
||||
createdAt: DateTime.now(),
|
||||
members: [ownerName],
|
||||
);
|
||||
|
||||
await _client.from('households').insert({
|
||||
'id': household.id,
|
||||
'name': household.name,
|
||||
'owner_name': household.ownerName,
|
||||
'created_at': household.createdAt.toIso8601String(),
|
||||
'members': household.members,
|
||||
});
|
||||
|
||||
print('✅ Household created: ${household.id}');
|
||||
return household;
|
||||
}
|
||||
|
||||
/// Get household by ID
|
||||
Future<Household?> getHousehold(String householdId) async {
|
||||
// Ensure we're signed in anonymously
|
||||
await signInAnonymously();
|
||||
|
||||
final response = await _client
|
||||
.from('households')
|
||||
.select()
|
||||
.eq('id', householdId)
|
||||
.single();
|
||||
|
||||
if (response == null) return null;
|
||||
|
||||
return Household(
|
||||
id: response['id'],
|
||||
name: response['name'],
|
||||
ownerName: response['owner_name'],
|
||||
createdAt: DateTime.parse(response['created_at']),
|
||||
members: List<String>.from(response['members']),
|
||||
);
|
||||
}
|
||||
|
||||
/// Join an existing household
|
||||
Future<Household> joinHousehold(String householdId, String userName) async {
|
||||
// Ensure we're signed in anonymously
|
||||
await signInAnonymously();
|
||||
|
||||
// Get current household
|
||||
final household = await getHousehold(householdId);
|
||||
if (household == null) {
|
||||
throw Exception('Household not found');
|
||||
}
|
||||
|
||||
// Add user to members if not already there
|
||||
if (!household.members.contains(userName)) {
|
||||
final updatedMembers = [...household.members, userName];
|
||||
|
||||
await _client.from('households').update({
|
||||
'members': updatedMembers,
|
||||
}).eq('id', householdId);
|
||||
|
||||
print('✅ Joined household: $householdId');
|
||||
|
||||
// Return updated household
|
||||
household.members = updatedMembers;
|
||||
return household;
|
||||
}
|
||||
|
||||
return household;
|
||||
}
|
||||
|
||||
/// Leave a household
|
||||
Future<void> leaveHousehold(String householdId, String userName) async {
|
||||
final household = await getHousehold(householdId);
|
||||
if (household == null) return;
|
||||
|
||||
final updatedMembers = household.members.where((m) => m != userName).toList();
|
||||
|
||||
await _client.from('households').update({
|
||||
'members': updatedMembers,
|
||||
}).eq('id', householdId);
|
||||
|
||||
print('✅ Left household: $householdId');
|
||||
}
|
||||
|
||||
/// Update household name
|
||||
Future<void> updateHouseholdName(String householdId, String newName) async {
|
||||
// Ensure we're signed in anonymously
|
||||
await signInAnonymously();
|
||||
|
||||
await _client.from('households').update({
|
||||
'name': newName,
|
||||
}).eq('id', householdId);
|
||||
|
||||
print('✅ Updated household name: $newName');
|
||||
}
|
||||
|
||||
/// Add food item to household inventory
|
||||
Future<void> addFoodItem(String householdId, FoodItem item, String localKey) async {
|
||||
// Ensure we're signed in anonymously
|
||||
await signInAnonymously();
|
||||
|
||||
await _client.from('food_items').insert({
|
||||
'household_id': householdId,
|
||||
'local_key': localKey,
|
||||
'name': item.name,
|
||||
'category': item.category,
|
||||
'barcode': item.barcode,
|
||||
'quantity': item.quantity,
|
||||
'unit': item.unit,
|
||||
'purchase_date': item.purchaseDate.toIso8601String(),
|
||||
'expiration_date': item.expirationDate.toIso8601String(),
|
||||
'notes': item.notes,
|
||||
'last_modified': item.lastModified?.toIso8601String() ?? DateTime.now().toIso8601String(),
|
||||
});
|
||||
|
||||
print('✅ Synced item to Supabase: ${item.name}');
|
||||
}
|
||||
|
||||
/// Update food item in household inventory
|
||||
Future<void> updateFoodItem(String householdId, FoodItem item, String localKey) async {
|
||||
await _client.from('food_items').update({
|
||||
'name': item.name,
|
||||
'category': item.category,
|
||||
'barcode': item.barcode,
|
||||
'quantity': item.quantity,
|
||||
'unit': item.unit,
|
||||
'purchase_date': item.purchaseDate.toIso8601String(),
|
||||
'expiration_date': item.expirationDate.toIso8601String(),
|
||||
'notes': item.notes,
|
||||
'last_modified': item.lastModified?.toIso8601String() ?? DateTime.now().toIso8601String(),
|
||||
}).eq('household_id', householdId).eq('local_key', localKey);
|
||||
|
||||
print('✅ Updated item in Supabase: ${item.name}');
|
||||
}
|
||||
|
||||
/// Delete food item from household inventory
|
||||
Future<void> deleteFoodItem(String householdId, String localKey) async {
|
||||
await _client
|
||||
.from('food_items')
|
||||
.delete()
|
||||
.eq('household_id', householdId)
|
||||
.eq('local_key', localKey);
|
||||
|
||||
print('✅ Deleted item from Supabase');
|
||||
}
|
||||
|
||||
/// Get all food items for a household
|
||||
Future<List<FoodItem>> getHouseholdItems(String householdId) async {
|
||||
final response = await _client
|
||||
.from('food_items')
|
||||
.select()
|
||||
.eq('household_id', householdId);
|
||||
|
||||
return (response as List).map<FoodItem>((item) {
|
||||
final foodItem = FoodItem();
|
||||
foodItem.name = item['name'];
|
||||
foodItem.category = item['category'];
|
||||
foodItem.barcode = item['barcode'];
|
||||
foodItem.quantity = item['quantity'];
|
||||
foodItem.unit = item['unit'];
|
||||
foodItem.purchaseDate = DateTime.parse(item['purchase_date']);
|
||||
foodItem.expirationDate = DateTime.parse(item['expiration_date']);
|
||||
foodItem.notes = item['notes'];
|
||||
foodItem.lastModified = DateTime.parse(item['last_modified']);
|
||||
foodItem.householdId = item['household_id'];
|
||||
return foodItem;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Subscribe to real-time updates for household items
|
||||
/// Returns a stream that emits whenever items change
|
||||
Stream<List<FoodItem>> subscribeToHouseholdItems(String householdId) {
|
||||
return _client
|
||||
.from('food_items')
|
||||
.stream(primaryKey: ['household_id', 'local_key'])
|
||||
.eq('household_id', householdId)
|
||||
.map((data) {
|
||||
return data.map<FoodItem>((item) {
|
||||
final foodItem = FoodItem();
|
||||
foodItem.name = item['name'];
|
||||
foodItem.category = item['category'];
|
||||
foodItem.barcode = item['barcode'];
|
||||
foodItem.quantity = item['quantity'];
|
||||
foodItem.unit = item['unit'];
|
||||
foodItem.purchaseDate = DateTime.parse(item['purchase_date']);
|
||||
foodItem.expirationDate = DateTime.parse(item['expiration_date']);
|
||||
foodItem.notes = item['notes'];
|
||||
foodItem.lastModified = DateTime.parse(item['last_modified']);
|
||||
foodItem.householdId = item['household_id'];
|
||||
return foodItem;
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
/// Sign in anonymously (no account needed!)
|
||||
/// This lets users sync without creating accounts
|
||||
Future<void> signInAnonymously() async {
|
||||
if (!isAuthenticated) {
|
||||
await _client.auth.signInAnonymously();
|
||||
print('✅ Signed in anonymously to Supabase');
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign out
|
||||
Future<void> signOut() async {
|
||||
await _client.auth.signOut();
|
||||
print('✅ Signed out from Supabase');
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user