diff --git a/README.md b/README.md index 0f00989..06a0b0c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,143 @@ -# Vendor-Helper -This is an application targeting small vendors and shop owners to maximize its profit and reduce the time cost. It has integrated ai that helps them to analyse the patterns and gain insight from it. +# Vendor-Helper (Shop Insights App) + +This is an application targeting small vendors and shop owners to maximize its profit and reduce the time cost. It has integrated AI that helps them to analyse the patterns and gain insight from it. + +## Project Structure + +``` +shop_insights_app/ +│ +├── lib/ +│ ├── main.dart # Main entry point +│ │ +│ ├── core/ +│ │ ├── constants.dart # App-wide constants +│ │ └── app_router.dart # Navigation routing +│ │ +│ ├── models/ +│ │ ├── sale_model.dart # Sales data model +│ │ ├── dashboard_summary.dart # Dashboard summary model +│ │ └── user_model.dart # User data model +│ │ +│ ├── repositories/ +│ │ ├── sales_repository.dart # Sales repository interface +│ │ ├── dummy_sales_repository.dart # Mock sales repository +│ │ ├── firebase_sales_repository.dart # Firebase sales repository +│ │ ├── insights_repository.dart # Insights repository interface +│ │ ├── dummy_insights_repository.dart # Mock insights repository +│ │ └── api_insights_repository.dart # API insights repository +│ │ +│ ├── services/ +│ │ ├── dashboard_service.dart # Dashboard business logic +│ │ ├── ocr_service.dart # OCR processing service +│ │ └── auth_service.dart # Authentication service +│ │ +│ ├── screens/ +│ │ ├── login_screen.dart # Login screen +│ │ ├── dashboard_screen.dart # Main dashboard +│ │ ├── add_sale_screen.dart # Add sale manually +│ │ ├── ocr_screen.dart # OCR receipt scanning +│ │ └── ai_insight_screen.dart # AI-powered insights +│ │ +│ ├── widgets/ +│ │ ├── sales_chart.dart # Sales trend chart +│ │ ├── category_bar_chart.dart # Category revenue chart +│ │ ├── top_products_list.dart # Top products display +│ │ └── insight_card.dart # Insight card widget +│ │ +│ └── utils/ +│ └── parsers.dart # Data parsing utilities +│ +├── backend/ +│ ├── main.py # FastAPI backend server +│ ├── gemini_service.py # Gemini AI integration +│ ├── auth_middleware.py # Authentication middleware +│ └── requirements.txt # Python dependencies +│ +└── README.md +``` + +## Features + +- **Dashboard**: View comprehensive sales analytics and metrics +- **Sales Management**: Add sales manually or via OCR +- **OCR Receipt Scanning**: Automatically extract sale data from receipts +- **AI Insights**: Get AI-powered business insights and recommendations +- **Analytics**: Visualize sales trends, category performance, and top products +- **User Authentication**: Secure login and user management + +## Technology Stack + +### Frontend (Flutter) +- **Framework**: Flutter/Dart +- **State Management**: StatefulWidget (can be extended with Provider/Bloc) +- **UI Components**: Material Design 3 + +### Backend (Python) +- **Framework**: FastAPI +- **AI Integration**: Google Gemini AI +- **Authentication**: JWT-based (configurable) +- **CORS**: Enabled for cross-origin requests + +## Getting Started + +### Prerequisites +- Flutter SDK (3.0+) +- Python 3.8+ +- Gemini API Key (optional, for AI features) + +### Frontend Setup + +1. Install Flutter dependencies: +```bash +flutter pub get +``` + +2. Run the app: +```bash +flutter run +``` + +### Backend Setup + +1. Navigate to the backend directory: +```bash +cd backend +``` + +2. Install Python dependencies: +```bash +pip install -r requirements.txt +``` + +3. Set up environment variables (optional): +```bash +export GEMINI_API_KEY=your_api_key_here +``` + +4. Run the backend server: +```bash +python main.py +``` + +The backend will be available at `http://localhost:8000` + +## API Endpoints + +- `GET /` - API information +- `GET /health` - Health check +- `POST /api/insights` - Get AI-powered insights +- `POST /api/ocr/process` - Process OCR images + +## Future Enhancements + +- Firebase integration for real-time data sync +- Enhanced OCR with Google Vision API +- Advanced analytics and reporting +- Multi-user support +- Mobile app deployment +- Cloud deployment (AWS/GCP/Azure) + +## License + +This project is open source and available under the MIT License. diff --git a/backend/auth_middleware.py b/backend/auth_middleware.py new file mode 100644 index 0000000..865efbb --- /dev/null +++ b/backend/auth_middleware.py @@ -0,0 +1,66 @@ +from fastapi import HTTPException, Header +from typing import Optional + + +async def verify_token(authorization: Optional[str] = Header(None)) -> str: + """ + Verify the authentication token + + In production, this would: + 1. Verify the JWT token + 2. Check token expiration + 3. Validate user permissions + 4. Return user information + """ + if not authorization: + raise HTTPException( + status_code=401, + detail="Missing authorization header" + ) + + if not authorization.startswith("Bearer "): + raise HTTPException( + status_code=401, + detail="Invalid authorization header format" + ) + + token = authorization.replace("Bearer ", "") + + # TODO: Implement actual token verification + # For now, accept any non-empty token + if not token: + raise HTTPException( + status_code=401, + detail="Invalid token" + ) + + return token + + +def create_token(user_id: str) -> str: + """ + Create a JWT token for the user + + In production, this would: + 1. Create a JWT with user claims + 2. Set appropriate expiration + 3. Sign with secret key + """ + # TODO: Implement actual JWT token creation + return f"mock_token_{user_id}" + + +def decode_token(token: str) -> dict: + """ + Decode and validate a JWT token + + In production, this would: + 1. Verify token signature + 2. Check expiration + 3. Return user claims + """ + # TODO: Implement actual JWT decoding + return { + "user_id": "mock_user", + "email": "user@example.com" + } diff --git a/backend/gemini_service.py b/backend/gemini_service.py new file mode 100644 index 0000000..a7641fc --- /dev/null +++ b/backend/gemini_service.py @@ -0,0 +1,188 @@ +import os +from typing import Dict, Any, List, Optional +import google.generativeai as genai + + +class GeminiService: + def __init__(self): + # Initialize Gemini API + # In production, use environment variable for API key + api_key = os.getenv("GEMINI_API_KEY", "") + if api_key: + genai.configure(api_key=api_key) + self.model = genai.GenerativeModel('gemini-pro') + else: + self.model = None + + async def generate_insights( + self, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + category: Optional[str] = None + ) -> Dict[str, Any]: + """ + Generate business insights using Gemini AI + """ + if not self.model: + # Return mock data if API key not configured + return self._get_mock_insights() + + prompt = f""" + Analyze the following sales data and provide business insights: + + Period: {start_date} to {end_date} + Category: {category or 'All'} + + Please provide: + 1. Key insights about revenue trends + 2. Category performance analysis + 3. Product recommendations + 4. Potential areas for improvement + + Format your response as structured JSON with insights array. + """ + + try: + response = self.model.generate_content(prompt) + # Parse and structure the response + return self._parse_gemini_response(response.text) + except Exception as e: + print(f"Error generating insights: {e}") + return self._get_mock_insights() + + async def generate_suggestions( + self, + sales_data: Dict[str, Any] + ) -> List[str]: + """ + Generate actionable suggestions based on sales data + """ + if not self.model: + return self._get_mock_suggestions() + + prompt = f""" + Based on the following sales data, provide 5 actionable business suggestions: + + {sales_data} + + Focus on: + - Revenue optimization + - Inventory management + - Customer retention + - Product mix + - Operational efficiency + + Return only the suggestions as a numbered list. + """ + + try: + response = self.model.generate_content(prompt) + return self._parse_suggestions(response.text) + except Exception as e: + print(f"Error generating suggestions: {e}") + return self._get_mock_suggestions() + + async def analyze_trends( + self, + start_date: Optional[str] = None, + end_date: Optional[str] = None + ) -> Dict[str, Any]: + """ + Analyze sales trends and make predictions + """ + if not self.model: + return self._get_mock_trends() + + prompt = f""" + Analyze sales trends for the period {start_date} to {end_date}. + + Provide: + 1. Weekly trend analysis + 2. Monthly trend analysis + 3. Revenue predictions for next week + 4. Confidence level in predictions + + Format as JSON with trends and predictions. + """ + + try: + response = self.model.generate_content(prompt) + return self._parse_trends_response(response.text) + except Exception as e: + print(f"Error analyzing trends: {e}") + return self._get_mock_trends() + + def _parse_gemini_response(self, text: str) -> Dict[str, Any]: + """Parse Gemini response into structured format""" + # In production, implement proper JSON parsing + return self._get_mock_insights() + + def _parse_suggestions(self, text: str) -> List[str]: + """Parse suggestions from Gemini response""" + # In production, implement proper parsing + return self._get_mock_suggestions() + + def _parse_trends_response(self, text: str) -> Dict[str, Any]: + """Parse trends analysis from Gemini response""" + # In production, implement proper JSON parsing + return self._get_mock_trends() + + def _get_mock_insights(self) -> Dict[str, Any]: + """Return mock insights for testing""" + return { + "insights": [ + { + "type": "revenue_trend", + "title": "Revenue Increasing", + "description": "Your revenue has increased by 15% compared to last period", + "sentiment": "positive" + }, + { + "type": "top_category", + "title": "Electronics Leading", + "description": "Electronics category is generating the most revenue", + "sentiment": "neutral" + }, + { + "type": "slow_product", + "title": "Low Sales Alert", + "description": "Some products have seen declining sales", + "sentiment": "negative" + } + ], + "predictions": { + "next_week_revenue": 5000.0, + "confidence": 0.85 + } + } + + def _get_mock_suggestions(self) -> List[str]: + """Return mock suggestions for testing""" + return [ + "Focus on high-margin products", + "Consider bundling related products", + "Implement a loyalty program", + "Optimize inventory based on demand patterns", + "Analyze customer buying patterns" + ] + + def _get_mock_trends(self) -> Dict[str, Any]: + """Return mock trends for testing""" + return { + "trends": [ + { + "period": "weekly", + "trend": "upward", + "percentage": 12.5 + }, + { + "period": "monthly", + "trend": "stable", + "percentage": 2.3 + } + ], + "predictions": { + "next_week_revenue": 5000.0, + "confidence": 0.85 + } + } diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..4ca1234 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,125 @@ +from fastapi import FastAPI, HTTPException, Depends +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional, Dict, Any, List +from datetime import datetime +import uvicorn + +from gemini_service import GeminiService +from auth_middleware import verify_token + +app = FastAPI(title="Shop Insights API") + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, specify actual origins + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +gemini_service = GeminiService() + + +class InsightsRequest(BaseModel): + action: str + start_date: Optional[str] = None + end_date: Optional[str] = None + category: Optional[str] = None + sales_data: Optional[Dict[str, Any]] = None + + +class InsightsResponse(BaseModel): + insights: Optional[List[Dict[str, Any]]] = None + suggestions: Optional[List[str]] = None + trends: Optional[Dict[str, Any]] = None + predictions: Optional[Dict[str, Any]] = None + + +@app.get("/") +async def root(): + return { + "message": "Shop Insights API", + "version": "1.0.0", + "status": "running" + } + + +@app.get("/health") +async def health_check(): + return {"status": "healthy"} + + +@app.post("/api/insights", response_model=InsightsResponse) +async def get_insights( + request: InsightsRequest, + # token: str = Depends(verify_token) # Uncomment when auth is enabled +): + """ + Get AI-powered insights based on sales data + """ + try: + if request.action == "get_insights": + insights = await gemini_service.generate_insights( + start_date=request.start_date, + end_date=request.end_date, + category=request.category + ) + return InsightsResponse( + insights=insights.get("insights"), + predictions=insights.get("predictions") + ) + + elif request.action == "get_suggestions": + suggestions = await gemini_service.generate_suggestions( + sales_data=request.sales_data or {} + ) + return InsightsResponse(suggestions=suggestions) + + elif request.action == "analyze_trends": + trends = await gemini_service.analyze_trends( + start_date=request.start_date, + end_date=request.end_date + ) + return InsightsResponse( + trends=trends.get("trends"), + predictions=trends.get("predictions") + ) + + else: + raise HTTPException(status_code=400, detail="Invalid action") + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/ocr/process") +async def process_ocr( + # image: UploadFile, # Uncomment when implementing file upload + # token: str = Depends(verify_token) +): + """ + Process OCR for receipt images + """ + # TODO: Implement OCR processing + # This would use Google Vision API or similar service + return { + "success": True, + "extracted_data": { + "productName": "Sample Product", + "price": 29.99, + "quantity": 1, + "category": "General" + }, + "confidence": 0.85 + } + + +if __name__ == "__main__": + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + reload=True + ) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..06c0a1a --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.109.1 +uvicorn==0.24.0 +pydantic==2.5.0 +python-multipart==0.0.22 +google-generativeai==0.3.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-dotenv==1.0.0 diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart new file mode 100644 index 0000000..b36c6ef --- /dev/null +++ b/lib/core/app_router.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import '../screens/login_screen.dart'; +import '../screens/dashboard_screen.dart'; +import '../screens/add_sale_screen.dart'; +import '../screens/ocr_screen.dart'; +import '../screens/ai_insight_screen.dart'; + +class AppRouter { + static const String loginRoute = '/'; + static const String dashboardRoute = '/dashboard'; + static const String addSaleRoute = '/add-sale'; + static const String ocrRoute = '/ocr'; + static const String aiInsightRoute = '/ai-insight'; + + static Route generateRoute(RouteSettings settings) { + switch (settings.name) { + case loginRoute: + return MaterialPageRoute(builder: (_) => const LoginScreen()); + case dashboardRoute: + return MaterialPageRoute(builder: (_) => const DashboardScreen()); + case addSaleRoute: + return MaterialPageRoute(builder: (_) => const AddSaleScreen()); + case ocrRoute: + return MaterialPageRoute(builder: (_) => const OCRScreen()); + case aiInsightRoute: + return MaterialPageRoute(builder: (_) => const AIInsightScreen()); + default: + return MaterialPageRoute( + builder: (_) => Scaffold( + body: Center( + child: Text('No route defined for ${settings.name}'), + ), + ), + ); + } + } +} diff --git a/lib/core/constants.dart b/lib/core/constants.dart new file mode 100644 index 0000000..d00b1cd --- /dev/null +++ b/lib/core/constants.dart @@ -0,0 +1,27 @@ +class AppConstants { + // API Configuration + static const String apiBaseUrl = 'http://localhost:8000'; + static const String geminiApiEndpoint = '/api/insights'; + + // Firebase Configuration + static const String firebaseCollection = 'sales'; + + // App Settings + static const String appName = 'Shop Insights'; + static const String appVersion = '1.0.0'; + + // Storage Keys + static const String userTokenKey = 'user_token'; + static const String userIdKey = 'user_id'; + + // Date Formats + static const String dateFormat = 'yyyy-MM-dd'; + static const String dateTimeFormat = 'yyyy-MM-dd HH:mm:ss'; + + // Chart Settings + static const int maxChartDataPoints = 30; + static const int topProductsCount = 10; + + // OCR Settings + static const double ocrConfidenceThreshold = 0.7; +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..eb715dd --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'core/app_router.dart'; + +void main() { + runApp(const ShopInsightsApp()); +} + +class ShopInsightsApp extends StatelessWidget { + const ShopInsightsApp({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Shop Insights', + theme: ThemeData( + primarySwatch: Colors.blue, + useMaterial3: true, + ), + onGenerateRoute: AppRouter.generateRoute, + initialRoute: AppRouter.loginRoute, + ); + } +} diff --git a/lib/models/dashboard_summary.dart b/lib/models/dashboard_summary.dart new file mode 100644 index 0000000..323ae0a --- /dev/null +++ b/lib/models/dashboard_summary.dart @@ -0,0 +1,103 @@ +class DashboardSummary { + final double totalRevenue; + final int totalSales; + final double averageSaleValue; + final Map categoryRevenue; + final List topProducts; + final List dailySales; + + DashboardSummary({ + required this.totalRevenue, + required this.totalSales, + required this.averageSaleValue, + required this.categoryRevenue, + required this.topProducts, + required this.dailySales, + }); + + factory DashboardSummary.fromJson(Map json) { + return DashboardSummary( + totalRevenue: (json['totalRevenue'] as num).toDouble(), + totalSales: json['totalSales'] as int, + averageSaleValue: (json['averageSaleValue'] as num).toDouble(), + categoryRevenue: Map.from( + (json['categoryRevenue'] as Map).map( + (key, value) => MapEntry(key as String, (value as num).toDouble()), + ), + ), + topProducts: (json['topProducts'] as List) + .map((item) => TopProduct.fromJson(item as Map)) + .toList(), + dailySales: (json['dailySales'] as List) + .map((item) => DailySale.fromJson(item as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'totalRevenue': totalRevenue, + 'totalSales': totalSales, + 'averageSaleValue': averageSaleValue, + 'categoryRevenue': categoryRevenue, + 'topProducts': topProducts.map((p) => p.toJson()).toList(), + 'dailySales': dailySales.map((d) => d.toJson()).toList(), + }; + } +} + +class TopProduct { + final String productName; + final int quantity; + final double revenue; + + TopProduct({ + required this.productName, + required this.quantity, + required this.revenue, + }); + + factory TopProduct.fromJson(Map json) { + return TopProduct( + productName: json['productName'] as String, + quantity: json['quantity'] as int, + revenue: (json['revenue'] as num).toDouble(), + ); + } + + Map toJson() { + return { + 'productName': productName, + 'quantity': quantity, + 'revenue': revenue, + }; + } +} + +class DailySale { + final DateTime date; + final double revenue; + final int count; + + DailySale({ + required this.date, + required this.revenue, + required this.count, + }); + + factory DailySale.fromJson(Map json) { + return DailySale( + date: DateTime.parse(json['date'] as String), + revenue: (json['revenue'] as num).toDouble(), + count: json['count'] as int, + ); + } + + Map toJson() { + return { + 'date': date.toIso8601String(), + 'revenue': revenue, + 'count': count, + }; + } +} diff --git a/lib/models/sale_model.dart b/lib/models/sale_model.dart new file mode 100644 index 0000000..3cf70a3 --- /dev/null +++ b/lib/models/sale_model.dart @@ -0,0 +1,69 @@ +class SaleModel { + final String id; + final String productName; + final String category; + final double price; + final int quantity; + final double totalAmount; + final DateTime saleDate; + final String? notes; + + SaleModel({ + required this.id, + required this.productName, + required this.category, + required this.price, + required this.quantity, + required this.totalAmount, + required this.saleDate, + this.notes, + }); + + factory SaleModel.fromJson(Map json) { + return SaleModel( + id: json['id'] as String, + productName: json['productName'] as String, + category: json['category'] as String, + price: (json['price'] as num).toDouble(), + quantity: json['quantity'] as int, + totalAmount: (json['totalAmount'] as num).toDouble(), + saleDate: DateTime.parse(json['saleDate'] as String), + notes: json['notes'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'productName': productName, + 'category': category, + 'price': price, + 'quantity': quantity, + 'totalAmount': totalAmount, + 'saleDate': saleDate.toIso8601String(), + 'notes': notes, + }; + } + + SaleModel copyWith({ + String? id, + String? productName, + String? category, + double? price, + int? quantity, + double? totalAmount, + DateTime? saleDate, + String? notes, + }) { + return SaleModel( + id: id ?? this.id, + productName: productName ?? this.productName, + category: category ?? this.category, + price: price ?? this.price, + quantity: quantity ?? this.quantity, + totalAmount: totalAmount ?? this.totalAmount, + saleDate: saleDate ?? this.saleDate, + notes: notes ?? this.notes, + ); + } +} diff --git a/lib/models/user_model.dart b/lib/models/user_model.dart new file mode 100644 index 0000000..b5136a5 --- /dev/null +++ b/lib/models/user_model.dart @@ -0,0 +1,63 @@ +class UserModel { + final String id; + final String email; + final String displayName; + final String? shopName; + final String? phoneNumber; + final DateTime createdAt; + final bool isVerified; + + UserModel({ + required this.id, + required this.email, + required this.displayName, + this.shopName, + this.phoneNumber, + required this.createdAt, + this.isVerified = false, + }); + + factory UserModel.fromJson(Map json) { + return UserModel( + id: json['id'] as String, + email: json['email'] as String, + displayName: json['displayName'] as String, + shopName: json['shopName'] as String?, + phoneNumber: json['phoneNumber'] as String?, + createdAt: DateTime.parse(json['createdAt'] as String), + isVerified: json['isVerified'] as bool? ?? false, + ); + } + + Map toJson() { + return { + 'id': id, + 'email': email, + 'displayName': displayName, + 'shopName': shopName, + 'phoneNumber': phoneNumber, + 'createdAt': createdAt.toIso8601String(), + 'isVerified': isVerified, + }; + } + + UserModel copyWith({ + String? id, + String? email, + String? displayName, + String? shopName, + String? phoneNumber, + DateTime? createdAt, + bool? isVerified, + }) { + return UserModel( + id: id ?? this.id, + email: email ?? this.email, + displayName: displayName ?? this.displayName, + shopName: shopName ?? this.shopName, + phoneNumber: phoneNumber ?? this.phoneNumber, + createdAt: createdAt ?? this.createdAt, + isVerified: isVerified ?? this.isVerified, + ); + } +} diff --git a/lib/repositories/api_insights_repository.dart b/lib/repositories/api_insights_repository.dart new file mode 100644 index 0000000..b9f7720 --- /dev/null +++ b/lib/repositories/api_insights_repository.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../core/constants.dart'; +import 'insights_repository.dart'; + +class ApiInsightsRepository implements InsightsRepository { + final String baseUrl; + final http.Client client; + + ApiInsightsRepository({ + this.baseUrl = AppConstants.apiBaseUrl, + http.Client? client, + }) : client = client ?? http.Client(); + + @override + Future> getInsights({ + required DateTime startDate, + required DateTime endDate, + String? category, + }) async { + final uri = Uri.parse('$baseUrl${AppConstants.geminiApiEndpoint}'); + + final response = await client.post( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'action': 'get_insights', + 'start_date': startDate.toIso8601String(), + 'end_date': endDate.toIso8601String(), + 'category': category, + }), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body) as Map; + } else { + throw Exception('Failed to get insights: ${response.statusCode}'); + } + } + + @override + Future> getSuggestions({ + required Map salesData, + }) async { + final uri = Uri.parse('$baseUrl${AppConstants.geminiApiEndpoint}'); + + final response = await client.post( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'action': 'get_suggestions', + 'sales_data': salesData, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + return (data['suggestions'] as List).cast(); + } else { + throw Exception('Failed to get suggestions: ${response.statusCode}'); + } + } + + @override + Future> analyzeTrends({ + required DateTime startDate, + required DateTime endDate, + }) async { + final uri = Uri.parse('$baseUrl${AppConstants.geminiApiEndpoint}'); + + final response = await client.post( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'action': 'analyze_trends', + 'start_date': startDate.toIso8601String(), + 'end_date': endDate.toIso8601String(), + }), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body) as Map; + } else { + throw Exception('Failed to analyze trends: ${response.statusCode}'); + } + } +} diff --git a/lib/repositories/dummy_insights_repository.dart b/lib/repositories/dummy_insights_repository.dart new file mode 100644 index 0000000..6561b15 --- /dev/null +++ b/lib/repositories/dummy_insights_repository.dart @@ -0,0 +1,82 @@ +import 'insights_repository.dart'; + +class DummyInsightsRepository implements InsightsRepository { + @override + Future> getInsights({ + required DateTime startDate, + required DateTime endDate, + String? category, + }) async { + await Future.delayed(const Duration(seconds: 1)); + + return { + 'insights': [ + { + 'type': 'revenue_trend', + 'title': 'Revenue Increasing', + 'description': 'Your revenue has increased by 15% compared to last period', + 'sentiment': 'positive', + }, + { + 'type': 'top_category', + 'title': 'Electronics Leading', + 'description': 'Electronics category is generating the most revenue', + 'sentiment': 'neutral', + }, + { + 'type': 'slow_product', + 'title': 'Low Sales Alert', + 'description': 'Some products have seen declining sales in the past week', + 'sentiment': 'negative', + }, + ], + 'recommendations': [ + 'Consider stocking more electronics items', + 'Promote slow-moving products with discounts', + 'Analyze peak sales hours to optimize staffing', + ], + }; + } + + @override + Future> getSuggestions({ + required Map salesData, + }) async { + await Future.delayed(const Duration(milliseconds: 800)); + + return [ + 'Focus on high-margin products', + 'Consider bundling related products', + 'Implement a loyalty program', + 'Optimize inventory based on demand patterns', + 'Analyze customer buying patterns', + ]; + } + + @override + Future> analyzeTrends({ + required DateTime startDate, + required DateTime endDate, + }) async { + await Future.delayed(const Duration(milliseconds: 1200)); + + return { + 'trends': [ + { + 'period': 'weekly', + 'trend': 'upward', + 'percentage': 12.5, + }, + { + 'period': 'monthly', + 'trend': 'stable', + 'percentage': 2.3, + }, + ], + 'predictions': { + 'next_week_revenue': 5000.0, + 'confidence': 0.85, + }, + }; + } +} diff --git a/lib/repositories/dummy_sales_repository.dart b/lib/repositories/dummy_sales_repository.dart new file mode 100644 index 0000000..5c1d156 --- /dev/null +++ b/lib/repositories/dummy_sales_repository.dart @@ -0,0 +1,97 @@ +import '../models/sale_model.dart'; +import 'sales_repository.dart'; + +class DummySalesRepository implements SalesRepository { + final List _sales = []; + + DummySalesRepository() { + _initializeDummyData(); + } + + void _initializeDummyData() { + final now = DateTime.now(); + _sales.addAll([ + SaleModel( + id: '1', + productName: 'Laptop', + category: 'Electronics', + price: 999.99, + quantity: 2, + totalAmount: 1999.98, + saleDate: now.subtract(const Duration(days: 1)), + notes: 'Customer requested gift wrapping', + ), + SaleModel( + id: '2', + productName: 'Coffee Maker', + category: 'Appliances', + price: 79.99, + quantity: 1, + totalAmount: 79.99, + saleDate: now.subtract(const Duration(days: 2)), + ), + SaleModel( + id: '3', + productName: 'Office Chair', + category: 'Furniture', + price: 249.50, + quantity: 3, + totalAmount: 748.50, + saleDate: now.subtract(const Duration(days: 3)), + ), + ]); + } + + @override + Future> getSales({DateTime? startDate, DateTime? endDate}) async { + await Future.delayed(const Duration(milliseconds: 500)); + + if (startDate == null && endDate == null) { + return List.from(_sales); + } + + return _sales.where((sale) { + if (startDate != null && sale.saleDate.isBefore(startDate)) { + return false; + } + if (endDate != null && sale.saleDate.isAfter(endDate)) { + return false; + } + return true; + }).toList(); + } + + @override + Future addSale(SaleModel sale) async { + await Future.delayed(const Duration(milliseconds: 300)); + _sales.add(sale); + return sale; + } + + @override + Future updateSale(SaleModel sale) async { + await Future.delayed(const Duration(milliseconds: 300)); + final index = _sales.indexWhere((s) => s.id == sale.id); + if (index != -1) { + _sales[index] = sale; + return sale; + } + throw Exception('Sale not found'); + } + + @override + Future deleteSale(String saleId) async { + await Future.delayed(const Duration(milliseconds: 300)); + _sales.removeWhere((s) => s.id == saleId); + } + + @override + Future getSaleById(String saleId) async { + await Future.delayed(const Duration(milliseconds: 200)); + try { + return _sales.firstWhere((s) => s.id == saleId); + } catch (e) { + return null; + } + } +} diff --git a/lib/repositories/firebase_sales_repository.dart b/lib/repositories/firebase_sales_repository.dart new file mode 100644 index 0000000..aea9d44 --- /dev/null +++ b/lib/repositories/firebase_sales_repository.dart @@ -0,0 +1,77 @@ +import '../models/sale_model.dart'; +import 'sales_repository.dart'; + +class FirebaseSalesRepository implements SalesRepository { + // This would integrate with Firebase Firestore + // For now, this is a placeholder implementation + + @override + Future> getSales({DateTime? startDate, DateTime? endDate}) async { + // TODO: Implement Firebase Firestore query + // Example: + // Query query = FirebaseFirestore.instance.collection('sales'); + // if (startDate != null) { + // query = query.where('saleDate', isGreaterThanOrEqualTo: startDate); + // } + // if (endDate != null) { + // query = query.where('saleDate', isLessThanOrEqualTo: endDate); + // } + // final snapshot = await query.get(); + // return snapshot.docs.map((doc) => SaleModel.fromJson(doc.data())).toList(); + + throw UnimplementedError('Firebase integration not yet implemented'); + } + + @override + Future addSale(SaleModel sale) async { + // TODO: Implement Firebase Firestore add + // Example: + // final docRef = await FirebaseFirestore.instance + // .collection('sales') + // .add(sale.toJson()); + // return sale.copyWith(id: docRef.id); + + throw UnimplementedError('Firebase integration not yet implemented'); + } + + @override + Future updateSale(SaleModel sale) async { + // TODO: Implement Firebase Firestore update + // Example: + // await FirebaseFirestore.instance + // .collection('sales') + // .doc(sale.id) + // .update(sale.toJson()); + // return sale; + + throw UnimplementedError('Firebase integration not yet implemented'); + } + + @override + Future deleteSale(String saleId) async { + // TODO: Implement Firebase Firestore delete + // Example: + // await FirebaseFirestore.instance + // .collection('sales') + // .doc(saleId) + // .delete(); + + throw UnimplementedError('Firebase integration not yet implemented'); + } + + @override + Future getSaleById(String saleId) async { + // TODO: Implement Firebase Firestore get by ID + // Example: + // final doc = await FirebaseFirestore.instance + // .collection('sales') + // .doc(saleId) + // .get(); + // if (doc.exists) { + // return SaleModel.fromJson(doc.data()!); + // } + // return null; + + throw UnimplementedError('Firebase integration not yet implemented'); + } +} diff --git a/lib/repositories/insights_repository.dart b/lib/repositories/insights_repository.dart new file mode 100644 index 0000000..2fadf4c --- /dev/null +++ b/lib/repositories/insights_repository.dart @@ -0,0 +1,16 @@ +abstract class InsightsRepository { + Future> getInsights({ + required DateTime startDate, + required DateTime endDate, + String? category, + }); + + Future> getSuggestions({ + required Map salesData, + }); + + Future> analyzeTrends({ + required DateTime startDate, + required DateTime endDate, + }); +} diff --git a/lib/repositories/sales_repository.dart b/lib/repositories/sales_repository.dart new file mode 100644 index 0000000..3c35104 --- /dev/null +++ b/lib/repositories/sales_repository.dart @@ -0,0 +1,9 @@ +import '../models/sale_model.dart'; + +abstract class SalesRepository { + Future> getSales({DateTime? startDate, DateTime? endDate}); + Future addSale(SaleModel sale); + Future updateSale(SaleModel sale); + Future deleteSale(String saleId); + Future getSaleById(String saleId); +} diff --git a/lib/screens/add_sale_screen.dart b/lib/screens/add_sale_screen.dart new file mode 100644 index 0000000..d4691f3 --- /dev/null +++ b/lib/screens/add_sale_screen.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import '../models/sale_model.dart'; +import '../repositories/dummy_sales_repository.dart'; + +class AddSaleScreen extends StatefulWidget { + const AddSaleScreen({Key? key}) : super(key: key); + + @override + State createState() => _AddSaleScreenState(); +} + +class _AddSaleScreenState extends State { + final _formKey = GlobalKey(); + final _productNameController = TextEditingController(); + final _categoryController = TextEditingController(); + final _priceController = TextEditingController(); + final _quantityController = TextEditingController(); + final _notesController = TextEditingController(); + final _salesRepository = DummySalesRepository(); + bool _isLoading = false; + + @override + void dispose() { + _productNameController.dispose(); + _categoryController.dispose(); + _priceController.dispose(); + _quantityController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + Future _handleAddSale() async { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() => _isLoading = true); + + try { + final price = double.parse(_priceController.text); + final quantity = int.parse(_quantityController.text); + final totalAmount = price * quantity; + + final sale = SaleModel( + id: DateTime.now().millisecondsSinceEpoch.toString(), + productName: _productNameController.text, + category: _categoryController.text, + price: price, + quantity: quantity, + totalAmount: totalAmount, + saleDate: DateTime.now(), + notes: _notesController.text.isEmpty ? null : _notesController.text, + ); + + await _salesRepository.addSale(sale); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Sale added successfully')), + ); + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error adding sale: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Add Sale'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _productNameController, + decoration: const InputDecoration( + labelText: 'Product Name', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.inventory), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter product name'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _categoryController, + decoration: const InputDecoration( + labelText: 'Category', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.category), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter category'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _priceController, + decoration: const InputDecoration( + labelText: 'Price', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.attach_money), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter price'; + } + if (double.tryParse(value) == null) { + return 'Please enter a valid number'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _quantityController, + decoration: const InputDecoration( + labelText: 'Quantity', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.numbers), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter quantity'; + } + if (int.tryParse(value) == null) { + return 'Please enter a valid number'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _notesController, + decoration: const InputDecoration( + labelText: 'Notes (Optional)', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.note), + ), + maxLines: 3, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _isLoading ? null : _handleAddSale, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Add Sale', style: TextStyle(fontSize: 16)), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/ai_insight_screen.dart b/lib/screens/ai_insight_screen.dart new file mode 100644 index 0000000..a3eeafe --- /dev/null +++ b/lib/screens/ai_insight_screen.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import '../repositories/dummy_insights_repository.dart'; +import '../widgets/insight_card.dart'; + +class AIInsightScreen extends StatefulWidget { + const AIInsightScreen({Key? key}) : super(key: key); + + @override + State createState() => _AIInsightScreenState(); +} + +class _AIInsightScreenState extends State { + final _insightsRepository = DummyInsightsRepository(); + bool _isLoading = true; + Map? _insightsData; + + @override + void initState() { + super.initState(); + _loadInsights(); + } + + Future _loadInsights() async { + setState(() => _isLoading = true); + + try { + final endDate = DateTime.now(); + final startDate = endDate.subtract(const Duration(days: 30)); + + final insights = await _insightsRepository.getInsights( + startDate: startDate, + endDate: endDate, + ); + + setState(() { + _insightsData = insights; + _isLoading = false; + }); + } catch (e) { + setState(() => _isLoading = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error loading insights: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('AI Insights'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadInsights, + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _insightsData == null + ? const Center(child: Text('No insights available')) + : RefreshIndicator( + onRefresh: _loadInsights, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Business Insights', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Text( + 'AI-powered analysis of your sales data', + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 24), + ..._buildInsightsList(), + const SizedBox(height: 24), + const Text( + 'Recommendations', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + ..._buildRecommendationsList(), + ], + ), + ), + ), + ); + } + + List _buildInsightsList() { + final insights = _insightsData!['insights'] as List; + return insights.map((insight) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: InsightCard( + title: insight['title'] as String, + description: insight['description'] as String, + sentiment: insight['sentiment'] as String, + type: insight['type'] as String, + ), + ); + }).toList(); + } + + List _buildRecommendationsList() { + final recommendations = _insightsData!['recommendations'] as List; + return recommendations.asMap().entries.map((entry) { + final index = entry.key; + final recommendation = entry.value as String; + return Card( + child: ListTile( + leading: CircleAvatar( + child: Text('${index + 1}'), + ), + title: Text(recommendation), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + ), + ); + }).toList(); + } +} diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart new file mode 100644 index 0000000..4ae2047 --- /dev/null +++ b/lib/screens/dashboard_screen.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import '../core/app_router.dart'; +import '../models/dashboard_summary.dart'; +import '../repositories/dummy_sales_repository.dart'; +import '../services/dashboard_service.dart'; +import '../widgets/sales_chart.dart'; +import '../widgets/category_bar_chart.dart'; +import '../widgets/top_products_list.dart'; + +class DashboardScreen extends StatefulWidget { + const DashboardScreen({Key? key}) : super(key: key); + + @override + State createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends State { + late final DashboardService _dashboardService; + DashboardSummary? _summary; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _dashboardService = DashboardService( + salesRepository: DummySalesRepository(), + ); + _loadDashboard(); + } + + Future _loadDashboard() async { + setState(() => _isLoading = true); + + try { + final summary = await _dashboardService.getDashboardSummary(); + setState(() { + _summary = summary; + _isLoading = false; + }); + } catch (e) { + setState(() => _isLoading = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error loading dashboard: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Dashboard'), + actions: [ + IconButton( + icon: const Icon(Icons.insights), + onPressed: () { + Navigator.of(context).pushNamed(AppRouter.aiInsightRoute); + }, + ), + IconButton( + icon: const Icon(Icons.camera_alt), + onPressed: () { + Navigator.of(context).pushNamed(AppRouter.ocrRoute); + }, + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _summary == null + ? const Center(child: Text('No data available')) + : RefreshIndicator( + onRefresh: _loadDashboard, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSummaryCards(), + const SizedBox(height: 24), + const Text( + 'Sales Trend', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + SalesChart(dailySales: _summary!.dailySales), + const SizedBox(height: 24), + const Text( + 'Revenue by Category', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + CategoryBarChart( + categoryRevenue: _summary!.categoryRevenue, + ), + const SizedBox(height: 24), + const Text( + 'Top Products', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + TopProductsList(topProducts: _summary!.topProducts), + ], + ), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + Navigator.of(context).pushNamed(AppRouter.addSaleRoute); + }, + child: const Icon(Icons.add), + ), + ); + } + + Widget _buildSummaryCards() { + return Row( + children: [ + Expanded( + child: _buildSummaryCard( + 'Total Revenue', + '\$${_summary!.totalRevenue.toStringAsFixed(2)}', + Icons.attach_money, + Colors.green, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildSummaryCard( + 'Total Sales', + '${_summary!.totalSales}', + Icons.shopping_cart, + Colors.blue, + ), + ), + ], + ); + } + + Widget _buildSummaryCard( + String title, + String value, + IconData icon, + Color color, + ) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + fontSize: 14, + color: Colors.grey, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..e3d86ff --- /dev/null +++ b/lib/screens/login_screen.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import '../core/app_router.dart'; +import '../services/auth_service.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({Key? key}) : super(key: key); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _authService = AuthService(); + bool _isLoading = false; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _handleLogin() async { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() => _isLoading = true); + + try { + await _authService.login( + _emailController.text, + _passwordController.text, + ); + + if (mounted) { + Navigator.of(context).pushReplacementNamed(AppRouter.dashboardRoute); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Login failed: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon( + Icons.storefront, + size: 80, + color: Colors.blue, + ), + const SizedBox(height: 24), + const Text( + 'Shop Insights', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Text( + 'Login to access your dashboard', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + const SizedBox(height: 48), + TextFormField( + controller: _emailController, + decoration: const InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.email), + ), + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your email'; + } + if (!value.contains('@')) { + return 'Please enter a valid email'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.lock), + ), + obscureText: true, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your password'; + } + if (value.length < 6) { + return 'Password must be at least 6 characters'; + } + return null; + }, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _isLoading ? null : _handleLogin, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Login', style: TextStyle(fontSize: 16)), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/ocr_screen.dart b/lib/screens/ocr_screen.dart new file mode 100644 index 0000000..133ec8e --- /dev/null +++ b/lib/screens/ocr_screen.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import '../services/ocr_service.dart'; + +class OCRScreen extends StatefulWidget { + const OCRScreen({Key? key}) : super(key: key); + + @override + State createState() => _OCRScreenState(); +} + +class _OCRScreenState extends State { + final _ocrService = OCRService(); + bool _isProcessing = false; + Map? _extractedData; + + Future _pickAndProcessImage() async { + setState(() => _isProcessing = true); + + try { + // In a real app, this would use image_picker package + // For now, we'll simulate with a dummy image path + final ocrResult = await _ocrService.processImage('dummy_path'); + final saleData = await _ocrService.extractSaleData(ocrResult); + + setState(() { + _extractedData = saleData; + _isProcessing = false; + }); + } catch (e) { + setState(() => _isProcessing = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error processing image: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('OCR Scanner'), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Scan Receipt', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Text( + 'Take a photo of a receipt to automatically extract sale information', + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 32), + if (_isProcessing) + const Center( + child: Column( + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Processing image...'), + ], + ), + ) + else if (_extractedData != null) + Expanded( + child: _buildExtractedDataView(), + ) + else + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.camera_alt, + size: 100, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + const Text( + 'No receipt scanned yet', + style: TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _isProcessing ? null : _pickAndProcessImage, + icon: const Icon(Icons.camera_alt), + label: const Text('Scan Receipt'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ], + ), + ), + ); + } + + Widget _buildExtractedDataView() { + final requiresReview = _extractedData!['requiresReview'] as bool; + final confidence = _extractedData!['confidence'] as double; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'Extracted Data', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Chip( + label: Text( + 'Confidence: ${(confidence * 100).toStringAsFixed(0)}%', + style: const TextStyle(fontSize: 12), + ), + backgroundColor: confidence > 0.7 ? Colors.green : Colors.orange, + ), + ], + ), + if (requiresReview) + Container( + margin: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.orange.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + children: [ + Icon(Icons.warning, color: Colors.orange), + SizedBox(width: 8), + Expanded( + child: Text( + 'Please review the extracted data', + style: TextStyle(color: Colors.orange), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _buildDataRow('Product', _extractedData!['productName']), + _buildDataRow('Price', '\$${_extractedData!['price']}'), + _buildDataRow('Quantity', '${_extractedData!['quantity']}'), + _buildDataRow('Category', _extractedData!['category']), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + // Navigate to add sale screen with extracted data + Navigator.of(context).pop(); + }, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('Use This Data'), + ), + ], + ), + ), + ); + } + + Widget _buildDataRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + SizedBox( + width: 100, + child: Text( + label, + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ), + Text(value), + ], + ), + ); + } +} diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart new file mode 100644 index 0000000..6a05dfa --- /dev/null +++ b/lib/services/auth_service.dart @@ -0,0 +1,97 @@ +import '../models/user_model.dart'; + +class AuthService { + UserModel? _currentUser; + + UserModel? get currentUser => _currentUser; + bool get isAuthenticated => _currentUser != null; + + Future login(String email, String password) async { + // This would integrate with Firebase Auth or custom auth backend + // For now, this is a placeholder implementation + + await Future.delayed(const Duration(seconds: 1)); + + if (email.isEmpty || password.isEmpty) { + throw Exception('Email and password are required'); + } + + // Simulate successful login + _currentUser = UserModel( + id: 'user_123', + email: email, + displayName: email.split('@')[0], + shopName: 'Demo Shop', + phoneNumber: '+1234567890', + createdAt: DateTime.now(), + isVerified: true, + ); + + return _currentUser!; + } + + Future register({ + required String email, + required String password, + required String displayName, + String? shopName, + String? phoneNumber, + }) async { + // This would integrate with Firebase Auth or custom auth backend + // For now, this is a placeholder implementation + + await Future.delayed(const Duration(seconds: 1)); + + if (email.isEmpty || password.isEmpty || displayName.isEmpty) { + throw Exception('Email, password, and display name are required'); + } + + // Simulate successful registration + _currentUser = UserModel( + id: 'user_${DateTime.now().millisecondsSinceEpoch}', + email: email, + displayName: displayName, + shopName: shopName, + phoneNumber: phoneNumber, + createdAt: DateTime.now(), + isVerified: false, + ); + + return _currentUser!; + } + + Future logout() async { + await Future.delayed(const Duration(milliseconds: 500)); + _currentUser = null; + } + + Future resetPassword(String email) async { + await Future.delayed(const Duration(seconds: 1)); + + if (email.isEmpty) { + throw Exception('Email is required'); + } + + // Simulate password reset email sent + } + + Future updateProfile({ + String? displayName, + String? shopName, + String? phoneNumber, + }) async { + await Future.delayed(const Duration(milliseconds: 500)); + + if (_currentUser == null) { + throw Exception('No user is currently logged in'); + } + + _currentUser = _currentUser!.copyWith( + displayName: displayName ?? _currentUser!.displayName, + shopName: shopName ?? _currentUser!.shopName, + phoneNumber: phoneNumber ?? _currentUser!.phoneNumber, + ); + + return _currentUser!; + } +} diff --git a/lib/services/dashboard_service.dart b/lib/services/dashboard_service.dart new file mode 100644 index 0000000..16021bb --- /dev/null +++ b/lib/services/dashboard_service.dart @@ -0,0 +1,80 @@ +import '../models/dashboard_summary.dart'; +import '../models/sale_model.dart'; +import '../repositories/sales_repository.dart'; + +class DashboardService { + final SalesRepository salesRepository; + + DashboardService({required this.salesRepository}); + + Future getDashboardSummary({ + DateTime? startDate, + DateTime? endDate, + }) async { + final sales = await salesRepository.getSales( + startDate: startDate, + endDate: endDate, + ); + + // Calculate total revenue and sales count + double totalRevenue = 0; + int totalSales = sales.length; + Map categoryRevenue = {}; + Map productQuantities = {}; + Map productRevenue = {}; + Map dailySalesCount = {}; + Map dailySalesRevenue = {}; + + for (var sale in sales) { + totalRevenue += sale.totalAmount; + + // Category revenue + categoryRevenue[sale.category] = + (categoryRevenue[sale.category] ?? 0) + sale.totalAmount; + + // Product quantities and revenue + productQuantities[sale.productName] = + (productQuantities[sale.productName] ?? 0) + sale.quantity; + productRevenue[sale.productName] = + (productRevenue[sale.productName] ?? 0) + sale.totalAmount; + + // Daily sales + final dateKey = sale.saleDate.toIso8601String().split('T')[0]; + dailySalesCount[dateKey] = (dailySalesCount[dateKey] ?? 0) + 1; + dailySalesRevenue[dateKey] = + (dailySalesRevenue[dateKey] ?? 0) + sale.totalAmount; + } + + // Calculate average sale value + double averageSaleValue = totalSales > 0 ? totalRevenue / totalSales : 0; + + // Get top products + final topProductsList = productQuantities.entries + .map((entry) => TopProduct( + productName: entry.key, + quantity: entry.value, + revenue: productRevenue[entry.key] ?? 0, + )) + .toList() + ..sort((a, b) => b.revenue.compareTo(a.revenue)); + + // Get daily sales + final dailySalesList = dailySalesCount.entries + .map((entry) => DailySale( + date: DateTime.parse(entry.key), + revenue: dailySalesRevenue[entry.key] ?? 0, + count: entry.value, + )) + .toList() + ..sort((a, b) => a.date.compareTo(b.date)); + + return DashboardSummary( + totalRevenue: totalRevenue, + totalSales: totalSales, + averageSaleValue: averageSaleValue, + categoryRevenue: categoryRevenue, + topProducts: topProductsList.take(10).toList(), + dailySales: dailySalesList, + ); + } +} diff --git a/lib/services/ocr_service.dart b/lib/services/ocr_service.dart new file mode 100644 index 0000000..a3d9277 --- /dev/null +++ b/lib/services/ocr_service.dart @@ -0,0 +1,69 @@ +class OCRService { + Future> processImage(String imagePath) async { + // This would integrate with an OCR service (Google ML Kit, Tesseract, etc.) + // For now, this is a placeholder implementation + + await Future.delayed(const Duration(seconds: 2)); + + // Simulate OCR result + return { + 'success': true, + 'extractedData': { + 'productName': 'Sample Product', + 'price': 29.99, + 'quantity': 1, + 'category': 'General', + 'date': DateTime.now().toIso8601String(), + }, + 'confidence': 0.85, + 'rawText': ''' + Sample Receipt + Product: Sample Product + Price: \$29.99 + Qty: 1 + Total: \$29.99 + ''', + }; + } + + Future> extractSaleData(Map ocrResult) async { + // Parse OCR result and extract sale data + await Future.delayed(const Duration(milliseconds: 500)); + + if (!ocrResult['success']) { + throw Exception('OCR processing failed'); + } + + final extractedData = ocrResult['extractedData'] as Map; + final confidence = ocrResult['confidence'] as double; + + return { + 'productName': extractedData['productName'] ?? 'Unknown', + 'price': extractedData['price'] ?? 0.0, + 'quantity': extractedData['quantity'] ?? 1, + 'category': extractedData['category'] ?? 'General', + 'date': extractedData['date'] ?? DateTime.now().toIso8601String(), + 'confidence': confidence, + 'requiresReview': confidence < 0.7, + }; + } + + Future validateExtractedData(Map data) async { + // Validate extracted data + await Future.delayed(const Duration(milliseconds: 200)); + + if (data['productName'] == null || data['productName'].toString().isEmpty) { + return false; + } + + if (data['price'] == null || (data['price'] as num) <= 0) { + return false; + } + + if (data['quantity'] == null || (data['quantity'] as int) <= 0) { + return false; + } + + return true; + } +} diff --git a/lib/utils/parsers.dart b/lib/utils/parsers.dart new file mode 100644 index 0000000..7f02682 --- /dev/null +++ b/lib/utils/parsers.dart @@ -0,0 +1,120 @@ +class Parsers { + static double parsePrice(String priceString) { + // Remove currency symbols and whitespace + final cleaned = priceString + .replaceAll(RegExp(r'[^\d.]'), '') + .trim(); + + return double.tryParse(cleaned) ?? 0.0; + } + + static int parseQuantity(String quantityString) { + // Remove non-numeric characters + final cleaned = quantityString + .replaceAll(RegExp(r'[^\d]'), '') + .trim(); + + return int.tryParse(cleaned) ?? 1; + } + + static DateTime parseDate(String dateString) { + try { + return DateTime.parse(dateString); + } catch (e) { + // Try common date formats + final formats = [ + RegExp(r'(\d{4})-(\d{2})-(\d{2})'), // YYYY-MM-DD + RegExp(r'(\d{2})/(\d{2})/(\d{4})'), // MM/DD/YYYY + RegExp(r'(\d{2})-(\d{2})-(\d{4})'), // DD-MM-YYYY + ]; + + for (var format in formats) { + final match = format.firstMatch(dateString); + if (match != null) { + try { + if (format.pattern.startsWith(r'(\d{4})')) { + // YYYY-MM-DD + return DateTime( + int.parse(match.group(1)!), + int.parse(match.group(2)!), + int.parse(match.group(3)!), + ); + } else if (format.pattern.contains('/')) { + // MM/DD/YYYY + return DateTime( + int.parse(match.group(3)!), + int.parse(match.group(1)!), + int.parse(match.group(2)!), + ); + } else { + // DD-MM-YYYY + return DateTime( + int.parse(match.group(3)!), + int.parse(match.group(2)!), + int.parse(match.group(1)!), + ); + } + } catch (_) { + continue; + } + } + } + + return DateTime.now(); + } + } + + static String formatCurrency(double amount) { + return '\$${amount.toStringAsFixed(2)}'; + } + + static String formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + static String formatDateTime(DateTime dateTime) { + return '${formatDate(dateTime)} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}:${dateTime.second.toString().padLeft(2, '0')}'; + } + + static Map parseReceiptText(String text) { + final lines = text.split('\n'); + final result = { + 'products': >[], + 'total': 0.0, + 'date': null, + }; + + for (var line in lines) { + line = line.trim(); + if (line.isEmpty) continue; + + // Try to extract price + final priceMatch = RegExp(r'\$?(\d+\.?\d*)').firstMatch(line); + if (priceMatch != null) { + final price = parsePrice(priceMatch.group(1)!); + if (price > 0) { + result['products'].add({ + 'name': line.replaceAll(priceMatch.group(0)!, '').trim(), + 'price': price, + }); + } + } + + // Try to extract date + final dateMatch = RegExp(r'(\d{2}/\d{2}/\d{4}|\d{4}-\d{2}-\d{2})') + .firstMatch(line); + if (dateMatch != null) { + result['date'] = parseDate(dateMatch.group(1)!); + } + } + + // Calculate total + double total = 0; + for (var product in result['products']) { + total += product['price'] as double; + } + result['total'] = total; + + return result; + } +} diff --git a/lib/widgets/category_bar_chart.dart b/lib/widgets/category_bar_chart.dart new file mode 100644 index 0000000..4ee1734 --- /dev/null +++ b/lib/widgets/category_bar_chart.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +class CategoryBarChart extends StatelessWidget { + final Map categoryRevenue; + + const CategoryBarChart({ + Key? key, + required this.categoryRevenue, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (categoryRevenue.isEmpty) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: Text('No category data available'), + ), + ), + ); + } + + final sortedEntries = categoryRevenue.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + + final maxRevenue = sortedEntries.first.value; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: sortedEntries.map((entry) { + final percentage = (entry.value / maxRevenue); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + entry.key, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Text( + '\$${entry.value.toStringAsFixed(2)}', + style: const TextStyle(color: Colors.grey), + ), + ], + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: percentage, + minHeight: 20, + backgroundColor: Colors.grey[200], + valueColor: AlwaysStoppedAnimation( + _getColorForIndex(sortedEntries.indexOf(entry)), + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ); + } + + Color _getColorForIndex(int index) { + final colors = [ + Colors.blue, + Colors.green, + Colors.orange, + Colors.purple, + Colors.red, + Colors.teal, + ]; + return colors[index % colors.length]; + } +} diff --git a/lib/widgets/insight_card.dart b/lib/widgets/insight_card.dart new file mode 100644 index 0000000..154c3b8 --- /dev/null +++ b/lib/widgets/insight_card.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; + +class InsightCard extends StatelessWidget { + final String title; + final String description; + final String sentiment; + final String type; + + const InsightCard({ + Key? key, + required this.title, + required this.description, + required this.sentiment, + required this.type, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _buildSentimentIcon(), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + description, + style: const TextStyle( + fontSize: 14, + color: Colors.grey, + ), + ), + const SizedBox(height: 12), + Chip( + label: Text( + _getTypeLabel(), + style: const TextStyle(fontSize: 12), + ), + backgroundColor: _getSentimentColor().withOpacity(0.2), + labelStyle: TextStyle(color: _getSentimentColor()), + ), + ], + ), + ), + ); + } + + Widget _buildSentimentIcon() { + IconData icon; + Color color; + + switch (sentiment.toLowerCase()) { + case 'positive': + icon = Icons.trending_up; + color = Colors.green; + break; + case 'negative': + icon = Icons.trending_down; + color = Colors.red; + break; + default: + icon = Icons.trending_flat; + color = Colors.orange; + } + + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: color, size: 24), + ); + } + + String _getTypeLabel() { + switch (type) { + case 'revenue_trend': + return 'Revenue Trend'; + case 'top_category': + return 'Category Insight'; + case 'slow_product': + return 'Product Alert'; + default: + return 'Insight'; + } + } + + Color _getSentimentColor() { + switch (sentiment.toLowerCase()) { + case 'positive': + return Colors.green; + case 'negative': + return Colors.red; + default: + return Colors.blue; + } + } +} diff --git a/lib/widgets/sales_chart.dart b/lib/widgets/sales_chart.dart new file mode 100644 index 0000000..945764d --- /dev/null +++ b/lib/widgets/sales_chart.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import '../models/dashboard_summary.dart'; + +class SalesChart extends StatelessWidget { + final List dailySales; + + const SalesChart({ + Key? key, + required this.dailySales, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (dailySales.isEmpty) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: Text('No sales data available'), + ), + ), + ); + } + + final maxRevenue = dailySales + .map((s) => s.revenue) + .reduce((a, b) => a > b ? a : b); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Daily Revenue', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 200, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: dailySales.asMap().entries.map((entry) { + final sale = entry.value; + final height = (sale.revenue / maxRevenue) * 180; + + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Tooltip( + message: '\$${sale.revenue.toStringAsFixed(2)}', + child: Container( + height: height, + decoration: BoxDecoration( + color: Colors.blue, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(4), + topRight: Radius.circular(4), + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + '${sale.date.day}', + style: const TextStyle(fontSize: 10), + ), + ], + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/top_products_list.dart b/lib/widgets/top_products_list.dart new file mode 100644 index 0000000..7807934 --- /dev/null +++ b/lib/widgets/top_products_list.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import '../models/dashboard_summary.dart'; + +class TopProductsList extends StatelessWidget { + final List topProducts; + + const TopProductsList({ + Key? key, + required this.topProducts, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (topProducts.isEmpty) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: Text('No product data available'), + ), + ), + ); + } + + return Card( + child: Column( + children: topProducts.asMap().entries.map((entry) { + final index = entry.key; + final product = entry.value; + final isLast = index == topProducts.length - 1; + + return Column( + children: [ + ListTile( + leading: CircleAvatar( + backgroundColor: _getColorForRank(index), + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + title: Text( + product.productName, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text('Sold: ${product.quantity} units'), + trailing: Text( + '\$${product.revenue.toStringAsFixed(2)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + if (!isLast) const Divider(height: 1), + ], + ); + }).toList(), + ), + ); + } + + Color _getColorForRank(int rank) { + switch (rank) { + case 0: + return Colors.amber; + case 1: + return Colors.grey; + case 2: + return Colors.brown; + default: + return Colors.blue; + } + } +}