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

@@ -73,12 +73,25 @@ class HiveDatabase {
await box.put(household.id, household);
}
/// Clear all data
/// Clear all food items
static Future<void> clearAll() async {
final box = await getFoodBox();
await box.clear();
}
/// Clear ALL data (food, settings, households)
static Future<void> clearAllData() async {
final foodBox = await getFoodBox();
final settingsBox = await getSettingsBox();
final householdsBox = await getHouseholdsBox();
await foodBox.clear();
await settingsBox.clear();
await householdsBox.clear();
print('✅ All data cleared from Hive');
}
/// Close all boxes
static Future<void> closeAll() async {
await Hive.close();

View File

@@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/screens/add_item_screen.dart';
import '../../inventory/screens/barcode_scanner_screen.dart';
@@ -10,55 +8,11 @@ import '../../inventory/screens/inventory_screen.dart';
import '../../settings/screens/settings_screen.dart';
/// Home screen - Dashboard with expiring items and quick actions
class HomeScreen extends ConsumerStatefulWidget {
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
final _syncService = InventorySyncService();
@override
void initState() {
super.initState();
_startSyncIfNeeded();
}
@override
void dispose() {
_syncService.removeSyncCallback(_onItemsSync);
_syncService.stopSync();
super.dispose();
}
Future<void> _startSyncIfNeeded() async {
final settings = await HiveDatabase.getSettings();
if (settings.currentHouseholdId != null) {
try {
// Register callback to refresh UI when items sync
_syncService.addSyncCallback(_onItemsSync);
await _syncService.startSync(settings.currentHouseholdId!);
print('🔄 Started syncing inventory for household: ${settings.currentHouseholdId}');
} catch (e) {
print('Failed to start sync: $e');
}
}
}
void _onItemsSync() {
if (mounted) {
// Refresh all inventory providers when Firebase syncs
ref.invalidate(itemCountProvider);
ref.invalidate(expiringSoonProvider);
print('✅ UI refreshed after Firebase sync');
}
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final itemCount = ref.watch(itemCountProvider);
final expiringSoon = ref.watch(expiringSoonProvider);
@@ -216,14 +170,14 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
Expanded(
child: _buildActionCard(
context,
icon: Icons.book,
label: 'Recipes',
icon: Icons.settings,
label: 'Settings',
color: AppColors.primaryLight,
onTap: () {
// TODO: Navigate to recipes
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Recipes coming soon!'),
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
);
},

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

View File

@@ -1,13 +1,13 @@
import 'package:hive/hive.dart';
import '../../../data/local/hive_database.dart';
import '../../settings/models/app_settings.dart';
import '../../household/services/firebase_household_service.dart';
import '../../household/services/supabase_household_service.dart';
import '../models/food_item.dart';
import 'inventory_repository.dart';
/// Hive implementation of InventoryRepository with Firebase sync
/// Hive implementation of InventoryRepository with Supabase sync (FOSS!)
class InventoryRepositoryImpl implements InventoryRepository {
final _firebaseService = FirebaseHouseholdService();
final _supabaseService = SupabaseHouseholdService();
Future<Box<FoodItem>> get _box async => await HiveDatabase.getFoodBox();
/// Get the current household ID from settings
@@ -52,21 +52,21 @@ class InventoryRepositoryImpl implements InventoryRepository {
print('📝 Added item to Hive: ${item.name}, key=${item.key}, householdId=${item.householdId}');
// Sync to Firebase if in a household
// Sync to Supabase if in a household
if (item.householdId != null && item.key != null) {
print('🚀 Uploading item to Firebase: ${item.name} (key: ${item.key})');
print('🚀 Uploading item to Supabase: ${item.name} (key: ${item.key})');
try {
await _firebaseService.addFoodItem(
await _supabaseService.addFoodItem(
item.householdId!,
item,
item.key.toString(),
);
print('✅ Successfully uploaded to Firebase');
print('✅ Successfully uploaded to Supabase');
} catch (e) {
print('❌ Failed to sync item to Firebase: $e');
print('❌ Failed to sync item to Supabase: $e');
}
} else {
print('⚠️ Skipping Firebase sync: householdId=${item.householdId}, key=${item.key}');
print('⚠️ Skipping Supabase sync: householdId=${item.householdId}, key=${item.key}');
}
}
@@ -75,16 +75,16 @@ class InventoryRepositoryImpl implements InventoryRepository {
item.lastModified = DateTime.now();
await item.save();
// Sync to Firebase if in a household
// Sync to Supabase if in a household
if (item.householdId != null && item.key != null) {
try {
await _firebaseService.updateFoodItem(
await _supabaseService.updateFoodItem(
item.householdId!,
item,
item.key.toString(),
);
} catch (e) {
print('Failed to sync item update to Firebase: $e');
print('Failed to sync item update to Supabase: $e');
}
}
}
@@ -94,15 +94,15 @@ class InventoryRepositoryImpl implements InventoryRepository {
final box = await _box;
final item = box.get(id);
// Sync deletion to Firebase if in a household
// Sync deletion to Supabase if in a household
if (item != null && item.householdId != null) {
try {
await _firebaseService.deleteFoodItem(
await _supabaseService.deleteFoodItem(
item.householdId!,
id.toString(),
);
} catch (e) {
print('Failed to sync item deletion to Firebase: $e');
print('Failed to sync item deletion to Supabase: $e');
}
}

View File

@@ -17,14 +17,6 @@ class InventoryScreen extends ConsumerWidget {
return Scaffold(
appBar: AppBar(
title: const Text('📦 Inventory'),
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
// TODO: Search functionality
},
),
],
),
body: inventoryState.when(
data: (items) {

View File

@@ -25,6 +25,15 @@ class AppSettings extends HiveObject {
@HiveField(6)
String? currentHouseholdId; // ID of the household they're in
@HiveField(7)
String? supabaseUrl; // Supabase project URL (can use free tier OR self-hosted!)
@HiveField(8)
String? supabaseAnonKey; // Supabase anonymous key (public, safe to store)
@HiveField(9)
bool darkModeEnabled; // Dark mode toggle
AppSettings({
this.discordWebhookUrl,
this.expirationAlertsEnabled = true,
@@ -33,5 +42,8 @@ class AppSettings extends HiveObject {
this.sortBy = 'expiration',
this.userName,
this.currentHouseholdId,
this.supabaseUrl,
this.supabaseAnonKey,
this.darkModeEnabled = false,
});
}

View File

@@ -24,13 +24,16 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
sortBy: fields[4] as String,
userName: fields[5] as String?,
currentHouseholdId: fields[6] as String?,
supabaseUrl: fields[7] as String?,
supabaseAnonKey: fields[8] as String?,
darkModeEnabled: fields[9] as bool,
);
}
@override
void write(BinaryWriter writer, AppSettings obj) {
writer
..writeByte(7)
..writeByte(10)
..writeByte(0)
..write(obj.discordWebhookUrl)
..writeByte(1)
@@ -44,7 +47,13 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
..writeByte(5)
..write(obj.userName)
..writeByte(6)
..write(obj.currentHouseholdId);
..write(obj.currentHouseholdId)
..writeByte(7)
..write(obj.supabaseUrl)
..writeByte(8)
..write(obj.supabaseAnonKey)
..writeByte(9)
..write(obj.darkModeEnabled);
}
@override

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../core/constants/colors.dart';
import '../../../data/local/hive_database.dart';
import '../../household/services/firebase_household_service.dart';
import '../../household/services/supabase_household_service.dart';
import '../models/app_settings.dart';
import '../models/household.dart';
@@ -14,7 +14,7 @@ class HouseholdScreen extends StatefulWidget {
}
class _HouseholdScreenState extends State<HouseholdScreen> {
final _firebaseService = FirebaseHouseholdService();
final _supabaseService = SupabaseHouseholdService();
AppSettings? _settings;
Household? _household;
bool _isLoading = true;
@@ -31,8 +31,8 @@ class _HouseholdScreenState extends State<HouseholdScreen> {
if (settings.currentHouseholdId != null) {
try {
// Load from Firebase
household = await _firebaseService.getHousehold(settings.currentHouseholdId!);
// Load from Supabase
household = await _supabaseService.getHousehold(settings.currentHouseholdId!);
} catch (e) {
// Household not found
}
@@ -86,7 +86,7 @@ class _HouseholdScreenState extends State<HouseholdScreen> {
if (result != null && result.isNotEmpty) {
try {
// Create household in Firebase
final household = await _firebaseService.createHousehold(result, _settings!.userName!);
final household = await _supabaseService.createHousehold(result, _settings!.userName!);
// Also save to local Hive for offline access
await HiveDatabase.saveHousehold(household);
@@ -164,40 +164,24 @@ class _HouseholdScreenState extends State<HouseholdScreen> {
try {
final code = result.toUpperCase();
// Join household in Firebase
final success = await _firebaseService.joinHousehold(code, _settings!.userName!);
// Join household in Supabase
final household = await _supabaseService.joinHousehold(code, _settings!.userName!);
if (success) {
// Load the household data
final household = await _firebaseService.getHousehold(code);
// Save to local Hive for offline access
await HiveDatabase.saveHousehold(household);
if (household != null) {
// Save to local Hive for offline access
await HiveDatabase.saveHousehold(household);
_settings!.currentHouseholdId = household.id;
await _settings!.save();
_settings!.currentHouseholdId = household.id;
await _settings!.save();
await _loadData();
await _loadData();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Joined ${household.name}!'),
backgroundColor: AppColors.success,
),
);
}
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Household not found. Check the code and try again.'),
backgroundColor: AppColors.error,
),
);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Joined ${household.name}!'),
backgroundColor: AppColors.success,
),
);
}
} catch (e) {
if (mounted) {
@@ -254,6 +238,66 @@ class _HouseholdScreenState extends State<HouseholdScreen> {
}
}
Future<void> _editHouseholdName() async {
final nameController = TextEditingController(text: _household!.name);
final result = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Edit Household Name'),
content: TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Household Name',
hintText: 'e.g., Smith Family',
),
textCapitalization: TextCapitalization.words,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, nameController.text),
child: const Text('Save'),
),
],
),
);
if (result != null && result.isNotEmpty && result != _household!.name) {
try {
// Update in Supabase
await _supabaseService.updateHouseholdName(_household!.id, result);
// Update local
_household!.name = result;
await HiveDatabase.saveHousehold(_household!);
setState(() {});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Household name updated!'),
backgroundColor: AppColors.success,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error updating name: $e'),
backgroundColor: AppColors.error,
),
);
}
}
}
}
Future<void> _leaveHousehold() async {
final confirm = await showDialog<bool>(
context: context,
@@ -280,7 +324,7 @@ class _HouseholdScreenState extends State<HouseholdScreen> {
if (confirm == true && _household != null) {
// Leave household in Firebase
await _firebaseService.leaveHousehold(_household!.id, _settings!.userName!);
await _supabaseService.leaveHousehold(_household!.id, _settings!.userName!);
_settings!.currentHouseholdId = null;
await _settings!.save();
@@ -392,18 +436,40 @@ class _HouseholdScreenState extends State<HouseholdScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_household!.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
Row(
children: [
Expanded(
child: Text(
_household!.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.edit, size: 20),
onPressed: _editHouseholdName,
tooltip: 'Edit household name',
),
],
),
Text(
'Owner: ${_household!.ownerName}',
style: TextStyle(
color: Colors.grey[600],
),
Row(
children: [
Expanded(
child: Text(
'You: ${_settings!.userName ?? "Not set"}',
style: TextStyle(
color: Colors.grey[600],
),
),
),
IconButton(
icon: const Icon(Icons.edit, size: 18),
onPressed: _showNameInputDialog,
tooltip: 'Edit your name',
),
],
),
],
),

View File

@@ -1,9 +1,17 @@
import 'package:flutter/material.dart';
import 'dart:io';
import 'package:csv/csv.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../core/constants/colors.dart';
import '../../../core/constants/app_icon.dart';
import '../../../data/local/hive_database.dart';
import '../models/app_settings.dart';
import '../../notifications/services/discord_service.dart';
import '../../inventory/repositories/inventory_repository_impl.dart';
import '../../inventory/models/food_item.dart';
import 'privacy_policy_screen.dart';
import 'terms_of_service_screen.dart';
import 'household_screen.dart';
@@ -19,11 +27,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
final _discordService = DiscordService();
AppSettings? _settings;
bool _isLoading = true;
String _appVersion = '1.3.0';
@override
void initState() {
super.initState();
_loadSettings();
_loadAppVersion();
}
Future<void> _loadAppVersion() async {
final packageInfo = await PackageInfo.fromPlatform();
setState(() {
_appVersion = packageInfo.version;
});
}
Future<void> _loadSettings() async {
@@ -117,17 +134,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Display Section
_buildSectionHeader('Display'),
SwitchListTile(
title: const Text('Dark Mode'),
subtitle: const Text('Reduce eye strain with dark theme'),
value: _settings!.darkModeEnabled,
onChanged: (value) {
setState(() => _settings!.darkModeEnabled = value);
_saveSettings();
},
activeColor: AppColors.primary,
),
ListTile(
title: const Text('Default View'),
subtitle: const Text('Grid'),
subtitle: Text(_settings!.defaultView == 'grid' ? 'Grid' : 'List'),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
onTap: _showDefaultViewDialog,
),
ListTile(
title: const Text('Sort By'),
subtitle: const Text('Expiration Date'),
subtitle: Text(_getSortByDisplayName(_settings!.sortBy)),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
onTap: _showSortByDialog,
),
const Divider(),
@@ -138,7 +165,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
title: const Text('Export Data'),
subtitle: const Text('Export your inventory to CSV'),
leading: const Icon(Icons.file_download, color: AppColors.primary),
onTap: () {},
onTap: _exportData,
),
ListTile(
title: const Text('Clear All Data'),
@@ -158,15 +185,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
// TODO: Clear data
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('All data cleared'),
backgroundColor: AppColors.error,
),
);
onPressed: () async {
// Clear all data from Hive
await HiveDatabase.clearAllData();
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('All data cleared successfully'),
backgroundColor: AppColors.error,
),
);
}
},
child: const Text(
'Clear',
@@ -187,9 +218,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
title: Text('App Name'),
subtitle: Text('Sage - Kitchen Management'),
),
const ListTile(
title: Text('Version'),
subtitle: Text('1.0.0'),
ListTile(
title: const Text('Version'),
subtitle: Text(_appVersion),
),
const ListTile(
title: Text('Developer'),
@@ -233,7 +264,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
showLicensePage(
context: context,
applicationName: 'Sage',
applicationVersion: '1.0.0',
applicationVersion: _appVersion,
applicationIcon: const SageLeafIcon(
size: 64,
color: AppColors.primary,
@@ -262,6 +293,189 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Future<void> _exportData() async {
try {
final repository = InventoryRepositoryImpl();
final items = await repository.getAllItems();
if (items.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No items to export!')),
);
}
return;
}
// Create CSV data
List<List<dynamic>> csvData = [
['Name', 'Category', 'Location', 'Quantity', 'Unit', 'Barcode', 'Purchase Date', 'Expiration Date', 'Notes'],
];
for (var item in items) {
csvData.add([
item.name,
item.category ?? '',
item.location.displayName,
item.quantity,
item.unit ?? '',
item.barcode ?? '',
DateFormat('yyyy-MM-dd').format(item.purchaseDate),
DateFormat('yyyy-MM-dd').format(item.expirationDate),
item.notes ?? '',
]);
}
// Convert to CSV string
String csv = const ListToCsvConverter().convert(csvData);
// Save to temporary file
final directory = await getTemporaryDirectory();
final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
final filePath = '${directory.path}/sage_inventory_$timestamp.csv';
final file = File(filePath);
await file.writeAsString(csv);
// Share the file
await Share.shareXFiles(
[XFile(filePath)],
subject: 'Sage Inventory Export',
text: 'Exported ${items.length} items from Sage Kitchen Manager',
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Exported ${items.length} items!'),
backgroundColor: AppColors.success,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error exporting data: $e'),
backgroundColor: AppColors.error,
),
);
}
}
}
String _getSortByDisplayName(String sortBy) {
switch (sortBy) {
case 'expiration':
return 'Expiration Date';
case 'name':
return 'Name';
case 'location':
return 'Location';
default:
return 'Expiration Date';
}
}
Future<void> _showDefaultViewDialog() async {
final result = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Default View'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: const Text('Grid'),
leading: Radio<String>(
value: 'grid',
groupValue: _settings!.defaultView,
onChanged: (value) => Navigator.pop(context, value),
activeColor: AppColors.primary,
),
onTap: () => Navigator.pop(context, 'grid'),
),
ListTile(
title: const Text('List'),
leading: Radio<String>(
value: 'list',
groupValue: _settings!.defaultView,
onChanged: (value) => Navigator.pop(context, value),
activeColor: AppColors.primary,
),
onTap: () => Navigator.pop(context, 'list'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
),
);
if (result != null) {
setState(() => _settings!.defaultView = result);
await _saveSettings();
}
}
Future<void> _showSortByDialog() async {
final result = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Sort By'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: const Text('Expiration Date'),
leading: Radio<String>(
value: 'expiration',
groupValue: _settings!.sortBy,
onChanged: (value) => Navigator.pop(context, value),
activeColor: AppColors.primary,
),
onTap: () => Navigator.pop(context, 'expiration'),
),
ListTile(
title: const Text('Name'),
leading: Radio<String>(
value: 'name',
groupValue: _settings!.sortBy,
onChanged: (value) => Navigator.pop(context, value),
activeColor: AppColors.primary,
),
onTap: () => Navigator.pop(context, 'name'),
),
ListTile(
title: const Text('Location'),
leading: Radio<String>(
value: 'location',
groupValue: _settings!.sortBy,
onChanged: (value) => Navigator.pop(context, value),
activeColor: AppColors.primary,
),
onTap: () => Navigator.pop(context, 'location'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
),
);
if (result != null) {
setState(() => _settings!.sortBy = result);
await _saveSettings();
}
}
void _showDiscordSetup() {
final webhookController = TextEditingController(
text: _discordService.webhookUrl ?? '',

View File

@@ -1,26 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'core/constants/app_theme.dart';
import 'data/local/hive_database.dart';
import 'features/home/screens/home_screen.dart';
import 'features/settings/models/app_settings.dart';
// Provider to watch settings for dark mode
final settingsProvider = StreamProvider<AppSettings>((ref) async* {
final settings = await HiveDatabase.getSettings();
yield settings;
// Listen for changes (this will update when settings change)
while (true) {
await Future.delayed(const Duration(milliseconds: 500));
final updatedSettings = await HiveDatabase.getSettings();
yield updatedSettings;
}
});
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Firebase (gracefully handle if not configured)
try {
await Firebase.initializeApp();
print('✅ Firebase initialized successfully');
} catch (e) {
print('⚠️ Firebase initialization failed: $e');
print('Household sharing will not work without Firebase configuration.');
print('See FIREBASE_SETUP.md for setup instructions.');
}
// Initialize Hive database
await HiveDatabase.init();
// Initialize Supabase (FOSS Firebase alternative!)
// Cloud-first with optional self-hosting!
final settings = await HiveDatabase.getSettings();
// Default to hosted Supabase, or use custom server if configured
final supabaseUrl = settings.supabaseUrl ?? 'https://pxjvvduzlqediugxyasu.supabase.co';
final supabaseKey = settings.supabaseAnonKey ??
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB4anZ2ZHV6bHFlZGl1Z3h5YXN1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTk2MTUwNjQsImV4cCI6MjA3NTE5MTA2NH0.gPScm4q4PUDDqnFezYRQnVntiqq-glSIwzSWBhQyzwU';
await Supabase.initialize(
url: supabaseUrl,
anonKey: supabaseKey,
);
if (settings.supabaseUrl != null) {
print('✅ Using custom Supabase server: ${settings.supabaseUrl}');
} else {
print('✅ Using hosted Sage sync server (Supabase FOSS backend)');
}
runApp(
const ProviderScope(
child: SageApp(),
@@ -28,18 +51,34 @@ void main() async {
);
}
class SageApp extends StatelessWidget {
class SageApp extends ConsumerWidget {
const SageApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Sage 🌿',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.light, // We'll make this dynamic later
home: const HomeScreen(),
Widget build(BuildContext context, WidgetRef ref) {
final settingsAsync = ref.watch(settingsProvider);
return settingsAsync.when(
data: (settings) => MaterialApp(
title: 'Sage 🌿',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: settings.darkModeEnabled ? ThemeMode.dark : ThemeMode.light,
home: const HomeScreen(),
),
loading: () => const MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Center(child: CircularProgressIndicator()),
),
),
error: (_, __) => const MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Center(child: Text('Error loading settings')),
),
),
);
}
}