✨ 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>
228 lines
7.4 KiB
Dart
228 lines
7.4 KiB
Dart
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');
|
|
}
|
|
}
|