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:
2025-10-04 22:27:42 -04:00
parent af63e11abd
commit 7ab641a3c8
42 changed files with 1728 additions and 756 deletions

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View 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');
}
}