Initial commit: Sage Kitchen Management App v1.0.0

 Features implemented:
- Smart inventory tracking with Hive database
- Barcode scanning with auto-populated product info
- Multiple API fallbacks (Open Food Facts, UPCItemDB)
- Smart expiration date predictions by category
- Discord webhook notifications (persisted)
- Custom sage leaf vector icon
- Material Design 3 UI with sage green theme
- Privacy Policy & Terms of Service
- Local-first, privacy-focused architecture

🎨 UI/UX:
- Home dashboard with inventory stats
- Add Item screen with barcode integration
- Inventory list with expiration indicators
- Settings with persistent preferences
- About section with legal docs

🔧 Technical:
- Flutter 3.35.5 with Riverpod state management
- Hive 2.2.3 for local database
- Mobile scanner for barcode detection
- Feature-first architecture

🤖 Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-04 13:54:21 -04:00
commit 7be7b270e6
155 changed files with 13133 additions and 0 deletions

View File

@@ -0,0 +1,362 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants/colors.dart';
import '../../inventory/controllers/inventory_controller.dart';
import '../../inventory/screens/add_item_screen.dart';
import '../../inventory/screens/barcode_scanner_screen.dart';
import '../../inventory/screens/inventory_screen.dart';
import '../../settings/screens/settings_screen.dart';
/// Home screen - Dashboard with expiring items and quick actions
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final itemCount = ref.watch(itemCountProvider);
final expiringSoon = ref.watch(expiringSoonProvider);
return Scaffold(
appBar: AppBar(
title: const Text('🌿 Sage'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
);
},
),
],
),
body: RefreshIndicator(
onRefresh: () async {
ref.invalidate(itemCountProvider);
ref.invalidate(expiringSoonProvider);
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Welcome message
Text(
'Welcome to Sage!',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Your smart kitchen management system',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 32),
// Quick Stats Card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Quick Stats',
style:
Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_buildStatRow(
Icons.inventory_2,
'Items in inventory',
itemCount.when(
data: (count) => '$count',
loading: () => '...',
error: (_, __) => '0',
),
),
const SizedBox(height: 8),
_buildStatRow(
Icons.warning_amber,
'Expiring soon',
expiringSoon.when(
data: (items) => '${items.length}',
loading: () => '...',
error: (_, __) => '0',
),
),
const SizedBox(height: 8),
_buildStatRow(Icons.shopping_cart, 'Shopping list', '0'),
],
),
),
),
const SizedBox(height: 24),
// Quick Actions
Text(
'Quick Actions',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildActionCard(
context,
icon: Icons.add_circle_outline,
label: 'Add Item',
color: AppColors.primary,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const AddItemScreen(),
),
);
},
),
),
const SizedBox(width: 12),
Expanded(
child: _buildActionCard(
context,
icon: Icons.qr_code_scanner,
label: 'Scan Barcode',
color: AppColors.info,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const BarcodeScannerScreen(),
),
);
},
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildActionCard(
context,
icon: Icons.inventory,
label: 'View Inventory',
color: AppColors.warning2,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const InventoryScreen(),
),
);
},
),
),
const SizedBox(width: 12),
Expanded(
child: _buildActionCard(
context,
icon: Icons.book,
label: 'Recipes',
color: AppColors.primaryLight,
onTap: () {
// TODO: Navigate to recipes
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Recipes coming soon!'),
),
);
},
),
),
],
),
const SizedBox(height: 32),
// Getting started or expiring soon list
expiringSoon.when(
data: (items) {
if (items.isEmpty) {
return _buildGettingStarted(context);
} else {
return _buildExpiringSoonList(context, items);
}
},
loading: () => const SizedBox.shrink(),
error: (_, __) => _buildGettingStarted(context),
),
],
),
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const AddItemScreen(),
),
);
},
icon: const Icon(Icons.add),
label: const Text('Add Item'),
),
);
}
Widget _buildStatRow(IconData icon, String label, String value) {
return Row(
children: [
Icon(icon, size: 20, color: AppColors.primary),
const SizedBox(width: 8),
Expanded(
child: Text(label),
),
Text(
value,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
);
}
Widget _buildActionCard(
BuildContext context, {
required IconData icon,
required String label,
required Color color,
required VoidCallback onTap,
}) {
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 32,
color: color,
),
const SizedBox(height: 8),
Text(
label,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}
Widget _buildGettingStarted(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.primaryLight.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.primaryLight,
width: 2,
),
),
child: Row(
children: [
const Icon(
Icons.info_outline,
color: AppColors.primary,
size: 32,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Getting Started',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
const SizedBox(height: 4),
const Text(
'Add your first item to start tracking your kitchen inventory and preventing food waste!',
style: TextStyle(
fontSize: 14,
),
),
],
),
),
],
),
);
}
Widget _buildExpiringSoonList(BuildContext context, List items) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.warning_amber, color: AppColors.warning2),
const SizedBox(width: 8),
Text(
'Expiring Soon',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
Text(
'These items expire within 7 days. Use them soon!',
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 16),
...items.take(3).map((item) => Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Text(
item.location.emoji,
style: const TextStyle(fontSize: 24),
),
title: Text(item.name),
subtitle: Text('Expires in ${item.daysUntilExpiration} days'),
trailing: Icon(
Icons.warning,
color: Color(item.expirationStatus.colorValue),
),
),
)),
],
);
}
}

View File

@@ -0,0 +1,78 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/food_item.dart';
import '../repositories/inventory_repository.dart';
import '../repositories/inventory_repository_impl.dart';
/// Provider for the inventory repository
final inventoryRepositoryProvider = Provider<InventoryRepository>((ref) {
return InventoryRepositoryImpl();
});
/// Provider for the inventory controller
final inventoryControllerProvider =
StateNotifierProvider<InventoryController, AsyncValue<List<FoodItem>>>(
(ref) {
final repository = ref.watch(inventoryRepositoryProvider);
return InventoryController(repository);
},
);
/// Controller for managing inventory state
class InventoryController extends StateNotifier<AsyncValue<List<FoodItem>>> {
final InventoryRepository _repository;
InventoryController(this._repository) : super(const AsyncValue.loading()) {
loadItems();
}
/// Load all items from the database
Future<void> loadItems() async {
state = const AsyncValue.loading();
try {
final items = await _repository.getAllItems();
state = AsyncValue.data(items);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
/// Add a new item
Future<void> addItem(FoodItem item) async {
await _repository.addItem(item);
await loadItems(); // Refresh the list
}
/// Update an existing item
Future<void> updateItem(FoodItem item) async {
await _repository.updateItem(item);
await loadItems();
}
/// Delete an item
Future<void> deleteItem(int id) async {
await _repository.deleteItem(id);
await loadItems();
}
/// Get items by location
Future<List<FoodItem>> getItemsByLocation(Location location) async {
return await _repository.getItemsByLocation(location);
}
/// Get items expiring soon
Future<List<FoodItem>> getItemsExpiringSoon(int days) async {
return await _repository.getItemsExpiringWithinDays(days);
}
}
/// Provider for items expiring within 7 days
final expiringSoonProvider = FutureProvider<List<FoodItem>>((ref) async {
final repository = ref.watch(inventoryRepositoryProvider);
return await repository.getItemsExpiringWithinDays(7);
});
/// Provider for total item count
final itemCountProvider = FutureProvider<int>((ref) async {
final repository = ref.watch(inventoryRepositoryProvider);
return await repository.getItemCount();
});

View File

@@ -0,0 +1,178 @@
import 'package:hive/hive.dart';
part 'food_item.g.dart';
/// Represents a food item in the inventory
@HiveType(typeId: 0)
class FoodItem extends HiveObject {
// Basic Info
@HiveField(0)
late String name;
@HiveField(1)
String? barcode;
@HiveField(2)
late int quantity;
@HiveField(3)
String? unit; // "bottles", "lbs", "oz", "items"
// Dates
@HiveField(4)
late DateTime purchaseDate;
@HiveField(5)
late DateTime expirationDate;
// Organization
@HiveField(6)
late int locationIndex; // Store as int for Hive
@HiveField(7)
String? category; // Auto from barcode or manual
// Media & Notes
@HiveField(8)
String? photoUrl; // Cached from API or user uploaded
@HiveField(9)
String? notes;
// Multi-user support (for future phases)
@HiveField(10)
String? userId;
@HiveField(11)
String? householdId;
// Sync tracking
@HiveField(12)
DateTime? lastModified;
@HiveField(13)
bool syncedToCloud = false;
// Computed properties
Location get location => Location.values[locationIndex];
set location(Location loc) => locationIndex = loc.index;
int get daysUntilExpiration {
return expirationDate.difference(DateTime.now()).inDays;
}
ExpirationStatus get expirationStatus {
final days = daysUntilExpiration;
if (days < 0) return ExpirationStatus.expired;
if (days <= 3) return ExpirationStatus.critical;
if (days <= 7) return ExpirationStatus.warning;
if (days <= 14) return ExpirationStatus.caution;
return ExpirationStatus.fresh;
}
bool get isExpired => daysUntilExpiration < 0;
bool get isExpiringSoon => daysUntilExpiration <= 7 && daysUntilExpiration >= 0;
}
/// Location where food is stored
@HiveType(typeId: 1)
enum Location {
@HiveField(0)
fridge,
@HiveField(1)
freezer,
@HiveField(2)
pantry,
@HiveField(3)
spiceRack,
@HiveField(4)
countertop,
@HiveField(5)
other,
}
/// Expiration status based on days until expiration
@HiveType(typeId: 2)
enum ExpirationStatus {
@HiveField(0)
fresh, // > 14 days
@HiveField(1)
caution, // 8-14 days
@HiveField(2)
warning, // 4-7 days
@HiveField(3)
critical, // 1-3 days
@HiveField(4)
expired, // 0 or negative days
}
// Extension to get user-friendly names for Location
extension LocationExtension on Location {
String get displayName {
switch (this) {
case Location.fridge:
return 'Fridge';
case Location.freezer:
return 'Freezer';
case Location.pantry:
return 'Pantry';
case Location.spiceRack:
return 'Spice Rack';
case Location.countertop:
return 'Countertop';
case Location.other:
return 'Other';
}
}
String get emoji {
switch (this) {
case Location.fridge:
return '🧊';
case Location.freezer:
return '❄️';
case Location.pantry:
return '🗄️';
case Location.spiceRack:
return '🧂';
case Location.countertop:
return '🪴';
case Location.other:
return '📦';
}
}
}
// Extension for ExpirationStatus
extension ExpirationStatusExtension on ExpirationStatus {
String get displayName {
switch (this) {
case ExpirationStatus.fresh:
return 'Fresh';
case ExpirationStatus.caution:
return 'Use within 2 weeks';
case ExpirationStatus.warning:
return 'Use soon';
case ExpirationStatus.critical:
return 'Use now!';
case ExpirationStatus.expired:
return 'Expired';
}
}
int get colorValue {
switch (this) {
case ExpirationStatus.fresh:
return 0xFF4CAF50; // Green
case ExpirationStatus.caution:
return 0xFFFFEB3B; // Yellow
case ExpirationStatus.warning:
return 0xFFFF9800; // Orange
case ExpirationStatus.critical:
return 0xFFF44336; // Red
case ExpirationStatus.expired:
return 0xFF9E9E9E; // Gray
}
}
}

View File

@@ -0,0 +1,192 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'food_item.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class FoodItemAdapter extends TypeAdapter<FoodItem> {
@override
final int typeId = 0;
@override
FoodItem read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return FoodItem()
..name = fields[0] as String
..barcode = fields[1] as String?
..quantity = fields[2] as int
..unit = fields[3] as String?
..purchaseDate = fields[4] as DateTime
..expirationDate = fields[5] as DateTime
..locationIndex = fields[6] as int
..category = fields[7] as String?
..photoUrl = fields[8] as String?
..notes = fields[9] as String?
..userId = fields[10] as String?
..householdId = fields[11] as String?
..lastModified = fields[12] as DateTime?
..syncedToCloud = fields[13] as bool;
}
@override
void write(BinaryWriter writer, FoodItem obj) {
writer
..writeByte(14)
..writeByte(0)
..write(obj.name)
..writeByte(1)
..write(obj.barcode)
..writeByte(2)
..write(obj.quantity)
..writeByte(3)
..write(obj.unit)
..writeByte(4)
..write(obj.purchaseDate)
..writeByte(5)
..write(obj.expirationDate)
..writeByte(6)
..write(obj.locationIndex)
..writeByte(7)
..write(obj.category)
..writeByte(8)
..write(obj.photoUrl)
..writeByte(9)
..write(obj.notes)
..writeByte(10)
..write(obj.userId)
..writeByte(11)
..write(obj.householdId)
..writeByte(12)
..write(obj.lastModified)
..writeByte(13)
..write(obj.syncedToCloud);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is FoodItemAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class LocationAdapter extends TypeAdapter<Location> {
@override
final int typeId = 1;
@override
Location read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return Location.fridge;
case 1:
return Location.freezer;
case 2:
return Location.pantry;
case 3:
return Location.spiceRack;
case 4:
return Location.countertop;
case 5:
return Location.other;
default:
return Location.fridge;
}
}
@override
void write(BinaryWriter writer, Location obj) {
switch (obj) {
case Location.fridge:
writer.writeByte(0);
break;
case Location.freezer:
writer.writeByte(1);
break;
case Location.pantry:
writer.writeByte(2);
break;
case Location.spiceRack:
writer.writeByte(3);
break;
case Location.countertop:
writer.writeByte(4);
break;
case Location.other:
writer.writeByte(5);
break;
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is LocationAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class ExpirationStatusAdapter extends TypeAdapter<ExpirationStatus> {
@override
final int typeId = 2;
@override
ExpirationStatus read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return ExpirationStatus.fresh;
case 1:
return ExpirationStatus.caution;
case 2:
return ExpirationStatus.warning;
case 3:
return ExpirationStatus.critical;
case 4:
return ExpirationStatus.expired;
default:
return ExpirationStatus.fresh;
}
}
@override
void write(BinaryWriter writer, ExpirationStatus obj) {
switch (obj) {
case ExpirationStatus.fresh:
writer.writeByte(0);
break;
case ExpirationStatus.caution:
writer.writeByte(1);
break;
case ExpirationStatus.warning:
writer.writeByte(2);
break;
case ExpirationStatus.critical:
writer.writeByte(3);
break;
case ExpirationStatus.expired:
writer.writeByte(4);
break;
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ExpirationStatusAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,41 @@
import '../models/food_item.dart';
/// Repository interface for inventory operations
/// This defines the contract for inventory data access
abstract class InventoryRepository {
/// Get all items in inventory
Future<List<FoodItem>> getAllItems();
/// Get a single item by ID
Future<FoodItem?> getItemById(int id);
/// Add a new item to inventory
Future<void> addItem(FoodItem item);
/// Update an existing item
Future<void> updateItem(FoodItem item);
/// Delete an item
Future<void> deleteItem(int id);
/// Get items by location
Future<List<FoodItem>> getItemsByLocation(Location location);
/// Get items expiring within X days
Future<List<FoodItem>> getItemsExpiringWithinDays(int days);
/// Get all expired items
Future<List<FoodItem>> getExpiredItems();
/// Search items by name
Future<List<FoodItem>> searchItemsByName(String query);
/// Get count of all items
Future<int> getItemCount();
/// Watch all items (stream for real-time updates)
Stream<List<FoodItem>> watchAllItems();
/// Watch items expiring soon
Stream<List<FoodItem>> watchExpiringItems(int days);
}

View File

@@ -0,0 +1,114 @@
import 'package:hive/hive.dart';
import '../../../data/local/hive_database.dart';
import '../models/food_item.dart';
import 'inventory_repository.dart';
/// Hive implementation of InventoryRepository
class InventoryRepositoryImpl implements InventoryRepository {
Future<Box<FoodItem>> get _box async => await HiveDatabase.getFoodBox();
@override
Future<List<FoodItem>> getAllItems() async {
final box = await _box;
return box.values.toList();
}
@override
Future<FoodItem?> getItemById(int id) async {
final box = await _box;
return box.get(id);
}
@override
Future<void> addItem(FoodItem item) async {
final box = await _box;
item.lastModified = DateTime.now();
await box.add(item);
}
@override
Future<void> updateItem(FoodItem item) async {
item.lastModified = DateTime.now();
await item.save();
}
@override
Future<void> deleteItem(int id) async {
final box = await _box;
await box.delete(id);
}
@override
Future<List<FoodItem>> getItemsByLocation(Location location) async {
final box = await _box;
return box.values
.where((item) => item.location == location)
.toList();
}
@override
Future<List<FoodItem>> getItemsExpiringWithinDays(int days) async {
final box = await _box;
final targetDate = DateTime.now().add(Duration(days: days));
return box.values
.where((item) =>
item.expirationDate.isBefore(targetDate) &&
item.expirationDate.isAfter(DateTime.now()))
.toList()
..sort((a, b) => a.expirationDate.compareTo(b.expirationDate));
}
@override
Future<List<FoodItem>> getExpiredItems() async {
final box = await _box;
return box.values
.where((item) => item.expirationDate.isBefore(DateTime.now()))
.toList()
..sort((a, b) => a.expirationDate.compareTo(b.expirationDate));
}
@override
Future<List<FoodItem>> searchItemsByName(String query) async {
final box = await _box;
final lowerQuery = query.toLowerCase();
return box.values
.where((item) => item.name.toLowerCase().contains(lowerQuery))
.toList();
}
@override
Future<int> getItemCount() async {
final box = await _box;
return box.length;
}
@override
Stream<List<FoodItem>> watchAllItems() async* {
final box = await _box;
yield box.values.toList();
await for (final _ in box.watch()) {
yield box.values.toList();
}
}
@override
Stream<List<FoodItem>> watchExpiringItems(int days) async* {
final box = await _box;
final targetDate = DateTime.now().add(Duration(days: days));
yield box.values
.where((item) =>
item.expirationDate.isBefore(targetDate) &&
item.expirationDate.isAfter(DateTime.now()))
.toList();
await for (final _ in box.watch()) {
yield box.values
.where((item) =>
item.expirationDate.isBefore(targetDate) &&
item.expirationDate.isAfter(DateTime.now()))
.toList();
}
}
}

View File

@@ -0,0 +1,349 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../core/constants/colors.dart';
import '../models/food_item.dart';
import '../controllers/inventory_controller.dart';
import '../services/barcode_service.dart';
import 'barcode_scanner_screen.dart';
/// Screen for adding a new food item to inventory
class AddItemScreen extends ConsumerStatefulWidget {
final String? scannedBarcode;
const AddItemScreen({super.key, this.scannedBarcode});
@override
ConsumerState<AddItemScreen> createState() => _AddItemScreenState();
}
class _AddItemScreenState extends ConsumerState<AddItemScreen> {
final _formKey = GlobalKey<FormState>();
// Form controllers
final _nameController = TextEditingController();
final _quantityController = TextEditingController(text: '1');
final _unitController = TextEditingController();
final _notesController = TextEditingController();
// Form values
DateTime _purchaseDate = DateTime.now();
DateTime _expirationDate = DateTime.now().add(const Duration(days: 7));
Location _location = Location.fridge;
String? _category;
String? _barcode;
@override
void initState() {
super.initState();
// Pre-populate barcode if scanned and lookup product info
if (widget.scannedBarcode != null) {
_barcode = widget.scannedBarcode;
_lookupProductInfo();
}
}
Future<void> _lookupProductInfo() async {
if (_barcode == null) return;
// Show loading
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Looking up product info...'),
duration: Duration(seconds: 1),
),
);
}
final productInfo = await BarcodeService.lookupBarcode(_barcode!);
if (productInfo != null && mounted) {
setState(() {
// Auto-fill product name
_nameController.text = productInfo.name;
// Auto-fill category
_category = productInfo.category;
// Set smart expiration date based on category
final smartDays = BarcodeService.getSmartExpirationDays(productInfo.category);
_expirationDate = DateTime.now().add(Duration(days: smartDays));
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('✨ Auto-filled: ${productInfo.name}'),
backgroundColor: AppColors.success,
),
);
} else if (mounted) {
// Still keep the barcode, just let user fill in the rest
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Product not found in database. Barcode saved: $_barcode'),
backgroundColor: AppColors.warning,
duration: const Duration(seconds: 3),
),
);
}
}
@override
void dispose() {
_nameController.dispose();
_quantityController.dispose();
_unitController.dispose();
_notesController.dispose();
super.dispose();
}
Future<void> _selectDate(BuildContext context, bool isExpiration) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: isExpiration ? _expirationDate : _purchaseDate,
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (picked != null) {
setState(() {
if (isExpiration) {
_expirationDate = picked;
} else {
_purchaseDate = picked;
}
});
}
}
Future<void> _saveItem() async {
if (_formKey.currentState!.validate()) {
final item = FoodItem()
..name = _nameController.text.trim()
..barcode = _barcode
..quantity = int.tryParse(_quantityController.text) ?? 1
..unit = _unitController.text.trim().isEmpty
? null
: _unitController.text.trim()
..purchaseDate = _purchaseDate
..expirationDate = _expirationDate
..location = _location
..category = _category
..notes = _notesController.text.trim().isEmpty
? null
: _notesController.text.trim()
..lastModified = DateTime.now();
try {
await ref.read(inventoryControllerProvider.notifier).addItem(item);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${item.name} added to inventory! 🎉'),
backgroundColor: AppColors.success,
),
);
Navigator.pop(context);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error saving item: $e'),
backgroundColor: AppColors.error,
),
);
}
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Add Item'),
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// Name
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Item Name *',
hintText: 'e.g., Milk, Ranch Dressing',
prefixIcon: Icon(Icons.fastfood),
),
textCapitalization: TextCapitalization.words,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter an item name';
}
return null;
},
),
const SizedBox(height: 16),
// Barcode Scanner
OutlinedButton.icon(
onPressed: () async {
final barcode = await Navigator.push<String>(
context,
MaterialPageRoute(
builder: (context) => const BarcodeScannerScreen(),
),
);
if (barcode != null) {
setState(() {
_barcode = barcode;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Barcode scanned: $barcode'),
backgroundColor: AppColors.success,
),
);
}
}
},
icon: const Icon(Icons.qr_code_scanner),
label: Text(_barcode == null ? 'Scan Barcode' : 'Barcode: $_barcode'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
const SizedBox(height: 16),
// Quantity & Unit
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
controller: _quantityController,
decoration: const InputDecoration(
labelText: 'Quantity *',
prefixIcon: Icon(Icons.numbers),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Required';
}
if (int.tryParse(value) == null) {
return 'Invalid';
}
return null;
},
),
),
const SizedBox(width: 12),
Expanded(
flex: 3,
child: TextFormField(
controller: _unitController,
decoration: const InputDecoration(
labelText: 'Unit',
hintText: 'bottles, lbs, oz',
prefixIcon: Icon(Icons.scale),
),
),
),
],
),
const SizedBox(height: 16),
// Location
DropdownButtonFormField<Location>(
value: _location,
decoration: const InputDecoration(
labelText: 'Location *',
prefixIcon: Icon(Icons.location_on),
),
items: Location.values.map((location) {
return DropdownMenuItem(
value: location,
child: Text('${location.emoji} ${location.displayName}'),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() => _location = value);
}
},
),
const SizedBox(height: 16),
// Category
TextFormField(
decoration: const InputDecoration(
labelText: 'Category (Optional)',
hintText: 'Dairy, Produce, Condiments',
prefixIcon: Icon(Icons.category),
),
textCapitalization: TextCapitalization.words,
onChanged: (value) => _category = value.isEmpty ? null : value,
),
const SizedBox(height: 24),
// Purchase Date
ListTile(
title: const Text('Purchase Date'),
subtitle: Text(DateFormat('MMM dd, yyyy').format(_purchaseDate)),
leading: const Icon(Icons.shopping_cart, color: AppColors.primary),
trailing: const Icon(Icons.calendar_today),
onTap: () => _selectDate(context, false),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: Colors.grey.shade300),
),
),
const SizedBox(height: 12),
// Expiration Date
ListTile(
title: const Text('Expiration Date'),
subtitle: Text(DateFormat('MMM dd, yyyy').format(_expirationDate)),
leading: const Icon(Icons.event_busy, color: AppColors.warning2),
trailing: const Icon(Icons.calendar_today),
onTap: () => _selectDate(context, true),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: Colors.grey.shade300),
),
),
const SizedBox(height: 24),
// Notes
TextFormField(
controller: _notesController,
decoration: const InputDecoration(
labelText: 'Notes (Optional)',
hintText: 'Any additional details',
prefixIcon: Icon(Icons.note),
),
maxLines: 3,
),
const SizedBox(height: 32),
// Save Button
ElevatedButton.icon(
onPressed: _saveItem,
icon: const Icon(Icons.save),
label: const Text('Save Item'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../../../core/constants/colors.dart';
import 'add_item_screen.dart';
class BarcodeScannerScreen extends StatefulWidget {
const BarcodeScannerScreen({super.key});
@override
State<BarcodeScannerScreen> createState() => _BarcodeScannerScreenState();
}
class _BarcodeScannerScreenState extends State<BarcodeScannerScreen> {
final MobileScannerController controller = MobileScannerController();
bool _isScanning = true;
String? _scannedCode;
@override
void dispose() {
controller.dispose();
super.dispose();
}
void _onBarcodeDetected(String barcode) {
if (!_isScanning) return;
setState(() {
_isScanning = false;
_scannedCode = barcode;
});
// Show success and navigate to Add Item screen
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
// Pop scanner and push Add Item screen with barcode
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => AddItemScreen(scannedBarcode: barcode),
),
);
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Scan Barcode'),
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
actions: [
IconButton(
icon: const Icon(Icons.flash_on),
onPressed: () => controller.toggleTorch(),
),
IconButton(
icon: const Icon(Icons.cameraswitch),
onPressed: () => controller.switchCamera(),
),
],
),
body: Stack(
children: [
MobileScanner(
controller: controller,
onDetect: (capture) {
final List<Barcode> barcodes = capture.barcodes;
if (barcodes.isNotEmpty && _isScanning) {
final barcode = barcodes.first.rawValue ?? '';
if (barcode.isNotEmpty) {
_onBarcodeDetected(barcode);
}
}
},
),
// Overlay with scan area guide
Center(
child: Container(
width: 250,
height: 250,
decoration: BoxDecoration(
border: Border.all(
color: _scannedCode != null ? AppColors.success : AppColors.primary,
width: 3,
),
borderRadius: BorderRadius.circular(12),
),
child: _scannedCode != null
? Container(
color: AppColors.success.withOpacity(0.3),
child: const Center(
child: Icon(
Icons.check_circle,
color: Colors.white,
size: 64,
),
),
)
: null,
),
),
// Instructions at bottom
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_scannedCode != null ? Icons.check_circle : Icons.qr_code_scanner,
color: Colors.white,
size: 48,
),
const SizedBox(height: 16),
Text(
_scannedCode != null
? 'Barcode Scanned!'
: 'Position the barcode inside the frame',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_scannedCode ?? 'The barcode will be scanned automatically',
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
textAlign: TextAlign.center,
),
],
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../core/constants/colors.dart';
import '../models/food_item.dart';
import '../controllers/inventory_controller.dart';
import 'add_item_screen.dart';
/// Screen displaying all inventory items
class InventoryScreen extends ConsumerWidget {
const InventoryScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final inventoryState = ref.watch(inventoryControllerProvider);
return Scaffold(
appBar: AppBar(
title: const Text('📦 Inventory'),
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
// TODO: Search functionality
},
),
],
),
body: inventoryState.when(
data: (items) {
if (items.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 80,
color: Colors.grey.shade300,
),
const SizedBox(height: 16),
Text(
'No items yet!',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
Text(
'Tap + to add your first item',
style: TextStyle(color: Colors.grey.shade500),
),
],
),
);
}
// Sort by expiration date (soonest first)
final sortedItems = List<FoodItem>.from(items)
..sort((a, b) => a.expirationDate.compareTo(b.expirationDate));
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: sortedItems.length,
itemBuilder: (context, index) {
final item = sortedItems[index];
return _buildInventoryCard(context, ref, item);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: AppColors.error),
const SizedBox(height: 16),
Text('Error: $error'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.refresh(inventoryControllerProvider),
child: const Text('Retry'),
),
],
),
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const AddItemScreen(),
),
);
},
icon: const Icon(Icons.add),
label: const Text('Add Item'),
),
);
}
Widget _buildInventoryCard(
BuildContext context, WidgetRef ref, FoodItem item) {
final daysUntil = item.daysUntilExpiration;
final status = item.expirationStatus;
final statusColor = Color(status.colorValue);
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
leading: CircleAvatar(
backgroundColor: statusColor.withOpacity(0.2),
child: Text(
item.location.emoji,
style: const TextStyle(fontSize: 24),
),
),
title: Text(
item.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
'${item.location.displayName}${item.quantity} ${item.unit ?? "items"}',
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
item.isExpired
? Icons.warning
: item.isExpiringSoon
? Icons.schedule
: Icons.check_circle,
size: 16,
color: statusColor,
),
const SizedBox(width: 4),
Text(
item.isExpired
? 'Expired ${-daysUntil} days ago'
: 'Expires in $daysUntil days',
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w500,
fontSize: 13,
),
),
],
),
const SizedBox(height: 2),
Text(
'Exp: ${DateFormat('MMM dd, yyyy').format(item.expirationDate)}',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
),
],
),
trailing: PopupMenuButton<String>(
onSelected: (value) async {
if (value == 'delete') {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Item'),
content: Text('Delete ${item.name}?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'Delete',
style: TextStyle(color: AppColors.error),
),
),
],
),
);
if (confirm == true) {
await ref
.read(inventoryControllerProvider.notifier)
.deleteItem(item.key);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${item.name} deleted'),
backgroundColor: AppColors.error,
),
);
}
}
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, color: AppColors.error),
SizedBox(width: 8),
Text('Delete'),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,185 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class BarcodeService {
/// Lookup product info from barcode using multiple APIs
static Future<ProductInfo?> lookupBarcode(String barcode) async {
// Try Open Food Facts first (best for food)
final openFoodResult = await _tryOpenFoodFacts(barcode);
if (openFoodResult != null) return openFoodResult;
// Try UPCItemDB (good for vitamins, supplements, general products)
final upcItemDbResult = await _tryUPCItemDB(barcode);
if (upcItemDbResult != null) return upcItemDbResult;
return null;
}
/// Try Open Food Facts API
static Future<ProductInfo?> _tryOpenFoodFacts(String barcode) async {
try {
final response = await http.get(
Uri.parse('https://world.openfoodfacts.org/api/v0/product/$barcode.json'),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['status'] == 1) {
final product = data['product'];
return ProductInfo(
name: product['product_name'] ?? 'Unknown Product',
category: _extractCategory(product),
imageUrl: product['image_url'],
);
}
}
} catch (e) {
print('Open Food Facts error: $e');
}
return null;
}
/// Try UPCItemDB API (no key needed for limited requests)
static Future<ProductInfo?> _tryUPCItemDB(String barcode) async {
try {
final response = await http.get(
Uri.parse('https://api.upcitemdb.com/prod/trial/lookup?upc=$barcode'),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['items'] != null && data['items'].isNotEmpty) {
final item = data['items'][0];
return ProductInfo(
name: item['title'] ?? 'Unknown Product',
category: item['category'] ?? _guessCategoryFromTitle(item['title']),
imageUrl: item['images']?[0],
);
}
}
} catch (e) {
print('UPCItemDB error: $e');
}
return null;
}
/// Guess category from product title
static String? _guessCategoryFromTitle(String? title) {
if (title == null) return null;
final titleLower = title.toLowerCase();
if (titleLower.contains('vitamin') || titleLower.contains('supplement')) return 'Supplements';
if (titleLower.contains('protein') || titleLower.contains('powder')) return 'Supplements';
if (titleLower.contains('milk') || titleLower.contains('cheese')) return 'Dairy';
if (titleLower.contains('sauce') || titleLower.contains('dressing')) return 'Condiments';
if (titleLower.contains('drink') || titleLower.contains('beverage')) return 'Beverages';
return null;
}
static String? _extractCategory(Map<String, dynamic> product) {
// Try to get category from various fields
if (product['categories'] != null && product['categories'].toString().isNotEmpty) {
final categories = product['categories'].toString().split(',');
if (categories.isNotEmpty) {
return categories.first.trim();
}
}
if (product['food_groups'] != null) {
return product['food_groups'].toString();
}
return null;
}
/// Get smart expiration days based on category
static int getSmartExpirationDays(String? category) {
if (category == null) return 7; // Default 1 week
final categoryLower = category.toLowerCase();
// Dairy products
if (categoryLower.contains('milk') ||
categoryLower.contains('dairy') ||
categoryLower.contains('yogurt') ||
categoryLower.contains('cheese')) {
return 7; // 1 week
}
// Meat and seafood
if (categoryLower.contains('meat') ||
categoryLower.contains('chicken') ||
categoryLower.contains('beef') ||
categoryLower.contains('pork') ||
categoryLower.contains('fish') ||
categoryLower.contains('seafood')) {
return 3; // 3 days
}
// Produce
if (categoryLower.contains('fruit') ||
categoryLower.contains('vegetable') ||
categoryLower.contains('produce')) {
return 5; // 5 days
}
// Beverages
if (categoryLower.contains('beverage') ||
categoryLower.contains('drink') ||
categoryLower.contains('juice') ||
categoryLower.contains('soda')) {
return 30; // 30 days
}
// Condiments and sauces
if (categoryLower.contains('sauce') ||
categoryLower.contains('condiment') ||
categoryLower.contains('dressing') ||
categoryLower.contains('ketchup') ||
categoryLower.contains('mustard')) {
return 90; // 3 months
}
// Canned/packaged goods
if (categoryLower.contains('canned') ||
categoryLower.contains('packaged') ||
categoryLower.contains('snack')) {
return 180; // 6 months
}
// Bread and bakery
if (categoryLower.contains('bread') ||
categoryLower.contains('bakery') ||
categoryLower.contains('pastry')) {
return 5; // 5 days
}
// Supplements and vitamins
if (categoryLower.contains('supplement') ||
categoryLower.contains('vitamin') ||
categoryLower.contains('protein') ||
categoryLower.contains('pill')) {
return 365; // 1 year
}
return 7; // Default 1 week
}
}
class ProductInfo {
final String name;
final String? category;
final String? imageUrl;
ProductInfo({
required this.name,
this.category,
this.imageUrl,
});
}

View File

@@ -0,0 +1,68 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class DiscordService {
String? webhookUrl;
/// Send a notification to Discord
Future<bool> sendNotification({
required String title,
required String message,
String? imageUrl,
}) async {
if (webhookUrl == null || webhookUrl!.isEmpty) {
print('Discord webhook URL not configured');
return false;
}
try {
final embed = {
'title': title,
'description': message,
'color': 0x4CAF50, // Sage green (hex color)
'timestamp': DateTime.now().toIso8601String(),
};
if (imageUrl != null) {
embed['thumbnail'] = {'url': imageUrl};
}
final response = await http.post(
Uri.parse(webhookUrl!),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'embeds': [embed],
}),
);
return response.statusCode == 204;
} catch (e) {
print('Error sending Discord notification: $e');
return false;
}
}
/// Send expiration alert
Future<void> sendExpirationAlert({
required String itemName,
required int daysUntilExpiration,
}) async {
String emoji = '⚠️';
String urgency = 'Warning';
if (daysUntilExpiration <= 0) {
emoji = '🚨';
urgency = 'Expired';
} else if (daysUntilExpiration <= 3) {
emoji = '⚠️';
urgency = 'Critical';
}
await sendNotification(
title: '$emoji Food Expiration Alert - $urgency',
message: daysUntilExpiration <= 0
? '**$itemName** has expired!'
: '**$itemName** expires in $daysUntilExpiration day${daysUntilExpiration == 1 ? '' : 's'}!',
);
}
}

View File

@@ -0,0 +1,29 @@
import 'package:hive/hive.dart';
part 'app_settings.g.dart';
@HiveType(typeId: 3)
class AppSettings extends HiveObject {
@HiveField(0)
String? discordWebhookUrl;
@HiveField(1)
bool expirationAlertsEnabled;
@HiveField(2)
bool discordNotificationsEnabled;
@HiveField(3)
String defaultView; // 'grid' or 'list'
@HiveField(4)
String sortBy; // 'expiration', 'name', 'location'
AppSettings({
this.discordWebhookUrl,
this.expirationAlertsEnabled = true,
this.discordNotificationsEnabled = false,
this.defaultView = 'grid',
this.sortBy = 'expiration',
});
}

View File

@@ -0,0 +1,53 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_settings.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AppSettingsAdapter extends TypeAdapter<AppSettings> {
@override
final int typeId = 3;
@override
AppSettings read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return AppSettings(
discordWebhookUrl: fields[0] as String?,
expirationAlertsEnabled: fields[1] as bool,
discordNotificationsEnabled: fields[2] as bool,
defaultView: fields[3] as String,
sortBy: fields[4] as String,
);
}
@override
void write(BinaryWriter writer, AppSettings obj) {
writer
..writeByte(5)
..writeByte(0)
..write(obj.discordWebhookUrl)
..writeByte(1)
..write(obj.expirationAlertsEnabled)
..writeByte(2)
..write(obj.discordNotificationsEnabled)
..writeByte(3)
..write(obj.defaultView)
..writeByte(4)
..write(obj.sortBy);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AppSettingsAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import '../../../core/constants/colors.dart';
class PrivacyPolicyScreen extends StatelessWidget {
const PrivacyPolicyScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Privacy Policy'),
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
'Sage - Kitchen Management',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Last Updated: October 4, 2025',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
const SizedBox(height: 24),
_buildSection(
'Data Collection',
'Sage is designed with your privacy in mind. All your data is stored locally on your device. We do not collect, transmit, or sell any personal information.',
),
_buildSection(
'Local Storage',
'Your inventory data, settings, and preferences are stored locally using Hive database. This data never leaves your device unless you explicitly choose to export it.',
),
_buildSection(
'Camera Permissions',
'The app requests camera permission only for barcode scanning functionality. No photos or videos are stored or transmitted.',
),
_buildSection(
'Internet Access',
'The app uses internet connection to:\n• Look up product information from public databases (Open Food Facts, UPCItemDB)\n• Send Discord notifications (only if you configure webhook)\n\nNo personal data is sent to these services except the barcode number for product lookup.',
),
_buildSection(
'Discord Integration',
'If you enable Discord notifications, you provide your own webhook URL. Notifications are sent directly from your device to your Discord server. We do not have access to or store your webhook URL on any server.',
),
_buildSection(
'Third-Party Services',
'The app may use the following third-party services:\n• Open Food Facts API - for product information\n• UPCItemDB API - for product information\n\nPlease review their respective privacy policies.',
),
_buildSection(
'Data Security',
'Your data is stored locally on your device and protected by your device\'s security measures. We recommend keeping your device secured with a password or biometric lock.',
),
_buildSection(
'Children\'s Privacy',
'Sage does not knowingly collect any information from children under 13. The app is designed for general household use.',
),
_buildSection(
'Changes to Privacy Policy',
'We may update this privacy policy from time to time. Any changes will be reflected in the app with an updated "Last Updated" date.',
),
_buildSection(
'Contact Us',
'If you have questions about this privacy policy, please open an issue on our GitHub repository or contact us through the app store.',
),
const SizedBox(height: 32),
],
),
);
}
Widget _buildSection(String title, String content) {
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
const SizedBox(height: 8),
Text(
content,
style: const TextStyle(
fontSize: 14,
height: 1.5,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,321 @@
import 'package:flutter/material.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 'privacy_policy_screen.dart';
import 'terms_of_service_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final _discordService = DiscordService();
AppSettings? _settings;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadSettings();
}
Future<void> _loadSettings() async {
final settings = await HiveDatabase.getSettings();
setState(() {
_settings = settings;
_isLoading = false;
// Load Discord webhook into service
if (settings.discordWebhookUrl != null) {
_discordService.webhookUrl = settings.discordWebhookUrl;
}
});
}
Future<void> _saveSettings() async {
if (_settings != null) {
await _settings!.save();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: ListView(
children: [
const SizedBox(height: 16),
// Notifications Section
_buildSectionHeader('Notifications'),
SwitchListTile(
title: const Text('Expiration Alerts'),
subtitle: const Text('Get notified when items are expiring soon'),
value: _settings!.expirationAlertsEnabled,
onChanged: (value) {
setState(() => _settings!.expirationAlertsEnabled = value);
_saveSettings();
},
activeColor: AppColors.primary,
),
SwitchListTile(
title: const Text('Discord Notifications'),
subtitle: Text(_settings!.discordNotificationsEnabled
? 'Enabled - Tap to configure'
: 'Send alerts to Discord'),
value: _settings!.discordNotificationsEnabled,
onChanged: (value) {
if (value) {
_showDiscordSetup();
} else {
setState(() {
_settings!.discordNotificationsEnabled = false;
_settings!.discordWebhookUrl = null;
});
_saveSettings();
}
},
activeColor: AppColors.primary,
),
const Divider(),
// Display Section
_buildSectionHeader('Display'),
ListTile(
title: const Text('Default View'),
subtitle: const Text('Grid'),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
),
ListTile(
title: const Text('Sort By'),
subtitle: const Text('Expiration Date'),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
),
const Divider(),
// Data Section
_buildSectionHeader('Data'),
ListTile(
title: const Text('Export Data'),
subtitle: const Text('Export your inventory to CSV'),
leading: const Icon(Icons.file_download, color: AppColors.primary),
onTap: () {},
),
ListTile(
title: const Text('Clear All Data'),
subtitle: const Text('Delete all inventory items'),
leading: const Icon(Icons.delete_forever, color: AppColors.error),
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear All Data?'),
content: const Text(
'This will permanently delete all your inventory items. This action cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
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,
),
);
},
child: const Text(
'Clear',
style: TextStyle(color: AppColors.error),
),
),
],
),
);
},
),
const Divider(),
// About Section
_buildSectionHeader('About'),
const ListTile(
title: Text('App Name'),
subtitle: Text('Sage - Kitchen Management'),
),
const ListTile(
title: Text('Version'),
subtitle: Text('1.0.0'),
),
const ListTile(
title: Text('Developer'),
subtitle: Text('Built with ❤️ using Flutter'),
),
ListTile(
title: const Text('Privacy Policy'),
leading: const Icon(Icons.privacy_tip, color: AppColors.primary),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const PrivacyPolicyScreen(),
),
);
},
),
ListTile(
title: const Text('Terms of Service'),
leading: const Icon(Icons.description, color: AppColors.primary),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const TermsOfServiceScreen(),
),
);
},
),
ListTile(
title: const Text('Open Source Licenses'),
leading: const Icon(Icons.code, color: AppColors.primary),
trailing: const Icon(Icons.chevron_right),
onTap: () {
showLicensePage(
context: context,
applicationName: 'Sage',
applicationVersion: '1.0.0',
applicationIcon: const SageLeafIcon(
size: 64,
color: AppColors.primary,
),
);
},
),
const SizedBox(height: 32),
],
),
);
}
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
);
}
void _showDiscordSetup() {
final webhookController = TextEditingController(
text: _discordService.webhookUrl ?? '',
);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discord Webhook Setup'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'To receive Discord notifications:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text('1. Go to your Discord server settings'),
const Text('2. Go to Integrations → Webhooks'),
const Text('3. Create a new webhook'),
const Text('4. Copy the webhook URL'),
const Text('5. Paste it below:'),
const SizedBox(height: 16),
TextField(
controller: webhookController,
decoration: const InputDecoration(
labelText: 'Webhook URL',
hintText: 'https://discord.com/api/webhooks/...',
border: OutlineInputBorder(),
),
maxLines: 3,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
final webhookUrl = webhookController.text.trim();
_discordService.webhookUrl = webhookUrl;
// Test the webhook
final success = await _discordService.sendNotification(
title: '✅ Discord Connected!',
message: 'Sage kitchen management app is now connected to Discord.',
);
if (mounted) {
Navigator.pop(context);
if (success) {
// Save to Hive
setState(() {
_settings!.discordNotificationsEnabled = true;
_settings!.discordWebhookUrl = webhookUrl;
});
await _saveSettings();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Discord connected! Check your server.'),
backgroundColor: AppColors.success,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to connect. Check your webhook URL.'),
backgroundColor: AppColors.error,
),
);
}
}
},
child: const Text('Save & Test'),
),
],
),
);
}
}

View File

@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import '../../../core/constants/colors.dart';
class TermsOfServiceScreen extends StatelessWidget {
const TermsOfServiceScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Terms of Service'),
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
'Sage - Terms of Service',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Last Updated: October 4, 2025',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
const SizedBox(height: 24),
_buildSection(
'Acceptance of Terms',
'By downloading, installing, or using the Sage app, you agree to be bound by these Terms of Service. If you do not agree to these terms, please do not use the app.',
),
_buildSection(
'Use of the App',
'Sage is a personal kitchen management tool designed to help you track food inventory and reduce waste. You may use the app for personal, non-commercial purposes.',
),
_buildSection(
'User Responsibilities',
'You are responsible for:\n• Maintaining the security of your device\n• Ensuring accuracy of data you enter\n• Complying with food safety guidelines\n• Backing up your data if needed\n\nThe app is a tool to assist you - always use your best judgment regarding food safety.',
),
_buildSection(
'Disclaimer of Warranties',
'THE APP IS PROVIDED "AS IS" WITHOUT WARRANTIES OF ANY KIND. We do not guarantee:\n• Accuracy of product information from third-party APIs\n• Accuracy of automatically suggested expiration dates\n• Prevention of food spoilage or food-borne illness\n\nAlways check food quality and safety yourself before consumption.',
),
_buildSection(
'Limitation of Liability',
'To the maximum extent permitted by law, we shall not be liable for any damages arising from:\n• Use or inability to use the app\n• Food spoilage or food-borne illness\n• Data loss\n• Reliance on expiration date estimates\n\nYou use the app at your own risk.',
),
_buildSection(
'Third-Party Services',
'The app uses third-party APIs (Open Food Facts, UPCItemDB) for product information. We are not responsible for the accuracy, availability, or content of these services.',
),
_buildSection(
'Discord Integration',
'If you choose to use Discord notifications:\n• You are responsible for your webhook URL security\n• We are not responsible for Discord service availability\n• You must comply with Discord\'s Terms of Service',
),
_buildSection(
'Intellectual Property',
'The Sage app and its original content are provided under an open-source license. Third-party services and APIs have their own terms and licenses.',
),
_buildSection(
'Changes to Terms',
'We reserve the right to modify these terms at any time. Continued use of the app after changes constitutes acceptance of the new terms.',
),
_buildSection(
'Termination',
'You may stop using the app at any time by uninstalling it. Your local data will be removed with the app.',
),
_buildSection(
'Governing Law',
'These terms shall be governed by and construed in accordance with applicable local laws.',
),
_buildSection(
'Contact',
'For questions about these Terms of Service, please contact us through the app store or GitHub repository.',
),
const SizedBox(height: 16),
const Text(
'⚠️ FOOD SAFETY REMINDER',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.error,
),
),
const SizedBox(height: 8),
const Text(
'This app is a tracking tool only. Always inspect food for signs of spoilage, follow proper food safety guidelines, and use your best judgment. When in doubt, throw it out!',
style: TextStyle(
fontSize: 14,
height: 1.5,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 32),
],
),
);
}
Widget _buildSection(String title, String content) {
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
const SizedBox(height: 8),
Text(
content,
style: const TextStyle(
fontSize: 14,
height: 1.5,
),
),
],
),
);
}
}