diff --git a/README.md b/README.md index 0f00989..91851b0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,207 @@ -# 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 their profit and reduce time costs. It has integrated AI that helps them analyze patterns and gain insights from their sales data. + +## Features + +- 📊 **Dashboard Analytics**: View comprehensive sales statistics and visualizations +- 💰 **Sales Management**: Add, track, and manage sales transactions +- 📸 **OCR Receipt Scanning**: Scan receipts using camera or gallery with OCR technology +- 🤖 **AI-Powered Insights**: Get intelligent business recommendations from Google Gemini AI +- 📈 **Trend Analysis**: Analyze sales trends and predict future performance +- 📱 **Mobile-First Design**: Built with Flutter for cross-platform mobile experience + +## Architecture + +### Frontend (Flutter/Dart) +The mobile application is built using Flutter with a clean architecture pattern: + +- **Core**: Application constants and routing configuration +- **Models**: Data models for sales, users, and dashboard summaries +- **Repositories**: Data layer with support for dummy data, Firebase, and API backends +- **Services**: Business logic for dashboard, OCR, and authentication +- **Screens**: UI screens for login, dashboard, sales entry, OCR, and AI insights +- **Widgets**: Reusable UI components including charts and insight cards +- **Utils**: Utility functions for data parsing and formatting + +### Backend (Python/FastAPI) +RESTful API backend providing AI insights: + +- **FastAPI**: Modern, fast web framework for building APIs +- **Gemini AI Integration**: Google Gemini AI for generating business insights +- **Authentication**: JWT-based authentication middleware +- **CORS Support**: Configured for cross-origin requests + +## Project Structure + +``` +shop_insights_app/ +│ +├── lib/ +│ ├── main.dart +│ │ +│ ├── core/ +│ │ ├── constants.dart +│ │ └── app_router.dart +│ │ +│ ├── models/ +│ │ ├── sale_model.dart +│ │ ├── dashboard_summary.dart +│ │ └── user_model.dart +│ │ +│ ├── repositories/ +│ │ ├── sales_repository.dart +│ │ ├── dummy_sales_repository.dart +│ │ ├── firebase_sales_repository.dart +│ │ ├── insights_repository.dart +│ │ ├── dummy_insights_repository.dart +│ │ └── api_insights_repository.dart +│ │ +│ ├── services/ +│ │ ├── dashboard_service.dart +│ │ ├── ocr_service.dart +│ │ └── auth_service.dart +│ │ +│ ├── screens/ +│ │ ├── login_screen.dart +│ │ ├── dashboard_screen.dart +│ │ ├── add_sale_screen.dart +│ │ ├── ocr_screen.dart +│ │ └── ai_insight_screen.dart +│ │ +│ ├── widgets/ +│ │ ├── sales_chart.dart +│ │ ├── category_bar_chart.dart +│ │ ├── top_products_list.dart +│ │ └── insight_card.dart +│ │ +│ └── utils/ +│ └── parsers.dart +│ +├── backend/ +│ ├── main.py +│ ├── gemini_service.py +│ ├── auth_middleware.py +│ └── requirements.txt +│ +└── README.md +``` + +## Getting Started + +### Prerequisites + +- Flutter SDK (>=3.0.0) +- Dart SDK (>=3.0.0) +- Python (>=3.8) +- Firebase account (optional, for production) +- Google 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: +```bash +export GEMINI_API_KEY=your_gemini_api_key_here +export API_KEY=your_api_key_here # Optional +``` + +4. Run the backend server: +```bash +python main.py +``` + +The API will be available at `http://localhost:8000` + +### API Documentation + +Once the backend is running, access the interactive API documentation at: +- Swagger UI: `http://localhost:8000/docs` +- ReDoc: `http://localhost:8000/redoc` + +## Key Dependencies + +### Flutter +- `flutter/material.dart` - Material Design widgets +- `firebase_auth` - Firebase authentication +- `cloud_firestore` - Firebase Firestore database +- `google_ml_kit` - ML Kit for OCR functionality +- `fl_chart` - Beautiful charts and graphs +- `http` - HTTP client for API calls +- `image_picker` - Image picking from camera/gallery + +### Python +- `fastapi` - Modern web framework +- `uvicorn` - ASGI server +- `pydantic` - Data validation +- `google-generativeai` - Google Gemini AI SDK + +## Configuration + +### Firebase Setup +1. Create a Firebase project +2. Enable Authentication and Firestore +3. Download configuration files +4. Add to your Flutter project + +### Gemini AI Setup +1. Get API key from Google AI Studio +2. Set the `GEMINI_API_KEY` environment variable +3. The backend will automatically use it for AI insights + +## Development + +### Running Tests +```bash +# Flutter tests +flutter test + +# Python tests +pytest +``` + +### Code Style +```bash +# Flutter +flutter analyze + +# Python +black backend/ +flake8 backend/ +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Submit a pull request + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## Support + +For issues and questions, please open an issue on GitHub or contact the development team. diff --git a/backend/auth_middleware.py b/backend/auth_middleware.py new file mode 100644 index 0000000..a82dfc5 --- /dev/null +++ b/backend/auth_middleware.py @@ -0,0 +1,45 @@ +from fastapi import HTTPException, Security +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import os + + +security = HTTPBearer() + + +def authenticate_user(credentials: HTTPAuthorizationCredentials = Security(security)) -> dict: + """ + Middleware to authenticate API requests + + In production, this should validate JWT tokens or API keys + For now, this is a placeholder implementation + """ + token = credentials.credentials + + # TODO: Implement actual token validation + # For development, we'll accept any token + if not token: + raise HTTPException( + status_code=401, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Mock user data + return { + "user_id": "mock_user_123", + "email": "user@example.com", + } + + +def verify_api_key(api_key: str) -> bool: + """ + Verify API key for external integrations + """ + # Get expected API key from environment + expected_key = os.getenv('API_KEY') + + if not expected_key: + # In development mode without API_KEY set + return True + + return api_key == expected_key diff --git a/backend/gemini_service.py b/backend/gemini_service.py new file mode 100644 index 0000000..539a547 --- /dev/null +++ b/backend/gemini_service.py @@ -0,0 +1,145 @@ +import os +from typing import List, Dict, Any +import google.generativeai as genai + + +class GeminiService: + """Service for interacting with Google Gemini API""" + + def __init__(self): + # Configure Gemini API + api_key = os.getenv('GEMINI_API_KEY') + if not api_key: + print("Warning: GEMINI_API_KEY not set. Using mock responses.") + self.model = None + else: + genai.configure(api_key=api_key) + self.model = genai.GenerativeModel('gemini-pro') + + async def generate_business_insights(self, start_date: str, end_date: str) -> str: + """Generate business insights based on sales data""" + + if not self.model: + return self._mock_business_insights(start_date, end_date) + + prompt = f""" + Analyze the sales data from {start_date} to {end_date} and provide actionable business insights. + + Please include: + 1. Key trends and patterns + 2. Recommendations for improvement + 3. Action items for the business owner + + Format the response in a clear, structured way with emojis for better readability. + """ + + try: + response = self.model.generate_content(prompt) + return response.text + except Exception as e: + print(f"Error generating insights: {e}") + return self._mock_business_insights(start_date, end_date) + + async def generate_product_recommendations(self) -> List[str]: + """Generate product recommendations""" + + if not self.model: + return self._mock_product_recommendations() + + prompt = """ + Based on current market trends and sales patterns, suggest 5 products + that a small vendor or shop owner should consider stocking. + + Return only a list of product suggestions with brief reasons. + """ + + try: + response = self.model.generate_content(prompt) + recommendations = response.text.strip().split('\n') + return [rec.strip() for rec in recommendations if rec.strip()][:5] + except Exception as e: + print(f"Error generating recommendations: {e}") + return self._mock_product_recommendations() + + async def generate_sales_predictions(self, days_ahead: int) -> Dict[str, Any]: + """Generate sales predictions""" + + if not self.model: + return self._mock_sales_predictions(days_ahead) + + # For now, return mock data as prediction requires historical data + return self._mock_sales_predictions(days_ahead) + + async def analyze_trends(self, start_date: str, end_date: str) -> Dict[str, Any]: + """Analyze sales trends""" + + if not self.model: + return self._mock_trends_analysis(start_date, end_date) + + # For now, return mock data as trend analysis requires historical data + return self._mock_trends_analysis(start_date, end_date) + + # Mock response methods + def _mock_business_insights(self, start_date: str, end_date: str) -> str: + return f""" +Based on your sales data from {start_date} to {end_date}: + +📈 Key Insights: +• Electronics category shows the highest revenue with steady growth +• Weekend sales are 35% higher than weekday sales +• Average transaction value increased by 12% compared to last period + +💡 Recommendations: +• Stock up on Electronics items for the upcoming weekend +• Consider promotional campaigns for mid-week to boost sales +• Focus on upselling strategies to maintain the increasing transaction value + +🎯 Action Items: +• Monitor inventory levels for top-selling products +• Analyze customer feedback for product improvements +• Plan marketing activities for slower days + """ + + def _mock_product_recommendations(self) -> List[str]: + return [ + "Wireless Headphones - High demand in Electronics", + "Yoga Mat - Trending in Sports category", + "Smart Watch - Popular accessory", + "Organic Coffee - Growing Food category trend", + "LED Desk Lamp - Office essentials category", + ] + + def _mock_sales_predictions(self, days_ahead: int) -> Dict[str, Any]: + return { + "periodDays": days_ahead, + "predictedRevenue": 5420.50, + "confidence": 0.85, + "expectedSales": 127, + "trends": { + "Electronics": "increasing", + "Clothing": "stable", + "Food": "increasing", + "Books": "stable", + } + } + + def _mock_trends_analysis(self, start_date: str, end_date: str) -> Dict[str, Any]: + return { + "period": { + "start": start_date, + "end": end_date, + }, + "overallTrend": "positive", + "growthRate": 15.5, + "categoryTrends": { + "Electronics": {"trend": "up", "percentage": 22.3}, + "Clothing": {"trend": "stable", "percentage": 2.1}, + "Food": {"trend": "up", "percentage": 18.7}, + "Books": {"trend": "down", "percentage": -5.2}, + }, + "insights": [ + "Electronics showing strong upward trend", + "Food category gaining momentum", + "Books category needs attention", + ] + } diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..4be869f --- /dev/null +++ b/backend/main.py @@ -0,0 +1,122 @@ +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from datetime import datetime +from typing import List, Dict, Any +import os +import uvicorn + +from gemini_service import GeminiService +from auth_middleware import authenticate_user + +app = FastAPI(title="Shop Insights API", version="1.0.0") + +# CORS configuration +# TODO: In production, replace with specific allowed origins +allowed_origins = os.getenv("ALLOWED_ORIGINS", "*").split(",") +app.add_middleware( + CORSMiddleware, + allow_origins=allowed_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Initialize Gemini service +gemini_service = GeminiService() + + +# Request models +class InsightsRequest(BaseModel): + startDate: str + endDate: str + + +class PredictionsRequest(BaseModel): + daysAhead: int + + +class TrendsRequest(BaseModel): + startDate: str + endDate: str + + +# Response models +class InsightsResponse(BaseModel): + insights: str + + +class RecommendationsResponse(BaseModel): + recommendations: List[str] + + +@app.get("/") +async def root(): + """Root endpoint""" + return {"message": "Shop Insights API", "version": "1.0.0"} + + +@app.post("/api/insights/business", response_model=InsightsResponse) +async def get_business_insights(request: InsightsRequest): + """ + Get AI-powered business insights based on sales data + """ + try: + insights = await gemini_service.generate_business_insights( + start_date=request.startDate, + end_date=request.endDate + ) + return InsightsResponse(insights=insights) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/insights/recommendations", response_model=RecommendationsResponse) +async def get_product_recommendations(): + """ + Get AI-powered product recommendations + """ + try: + recommendations = await gemini_service.generate_product_recommendations() + return RecommendationsResponse(recommendations=recommendations) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/insights/predictions") +async def get_sales_predictions(request: PredictionsRequest) -> Dict[str, Any]: + """ + Get sales predictions for the specified period + """ + try: + predictions = await gemini_service.generate_sales_predictions( + days_ahead=request.daysAhead + ) + return predictions + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/insights/trends") +async def analyze_trends(request: TrendsRequest) -> Dict[str, Any]: + """ + Analyze sales trends for the specified period + """ + try: + trends = await gemini_service.analyze_trends( + start_date=request.startDate, + end_date=request.endDate + ) + return trends + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/health") +async def health_check(): + """Health check endpoint""" + return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()} + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..1dfc5f3 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.109.1 +uvicorn==0.24.0 +pydantic==2.5.0 +python-multipart==0.0.22 +google-generativeai==0.3.1 +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..a6f0ec9 --- /dev/null +++ b/lib/core/app_router.dart @@ -0,0 +1,43 @@ +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'; + +/// Application routing configuration +class AppRouter { + static const String login = '/'; + static const String dashboard = '/dashboard'; + static const String addSale = '/add-sale'; + static const String ocr = '/ocr'; + static const String aiInsight = '/ai-insight'; + + static Route generateRoute(RouteSettings settings) { + switch (settings.name) { + case login: + return MaterialPageRoute(builder: (_) => const LoginScreen()); + + case dashboard: + return MaterialPageRoute(builder: (_) => const DashboardScreen()); + + case addSale: + return MaterialPageRoute(builder: (_) => const AddSaleScreen()); + + case ocr: + return MaterialPageRoute(builder: (_) => const OCRScreen()); + + case aiInsight: + 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..94df08b --- /dev/null +++ b/lib/core/constants.dart @@ -0,0 +1,42 @@ +/// Application-wide constants +class AppConstants { + // API Configuration + static const String baseUrl = 'http://localhost:8000'; + static const String apiVersion = 'v1'; + + // Firebase Configuration + static const String firebaseCollectionSales = 'sales'; + static const String firebaseCollectionUsers = 'users'; + + // App Configuration + static const String appName = 'Shop Insights'; + static const String appVersion = '1.0.0'; + + // Date Formats + static const String dateFormat = 'yyyy-MM-dd'; + static const String dateTimeFormat = 'yyyy-MM-dd HH:mm:ss'; + + // Pagination + static const int defaultPageSize = 20; + + // Chart Configuration + static const int maxChartDataPoints = 30; + static const int topProductsLimit = 10; + + // Categories + static const List productCategories = [ + 'Electronics', + 'Clothing', + 'Food', + 'Books', + 'Home & Garden', + 'Sports', + 'Toys', + 'Other' + ]; + + // Error Messages + static const String genericError = 'An error occurred. Please try again.'; + static const String networkError = 'Network error. Please check your connection.'; + static const String authError = 'Authentication failed. Please login again.'; +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..84040e0 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'core/app_router.dart'; +import 'services/auth_service.dart'; + +void main() { + runApp(const ShopInsightsApp()); +} + +class ShopInsightsApp extends StatelessWidget { + const ShopInsightsApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Shop Insights', + theme: ThemeData( + primarySwatch: Colors.blue, + useMaterial3: true, + ), + initialRoute: AppRouter.login, + onGenerateRoute: AppRouter.generateRoute, + ); + } +} diff --git a/lib/models/dashboard_summary.dart b/lib/models/dashboard_summary.dart new file mode 100644 index 0000000..5a442c0 --- /dev/null +++ b/lib/models/dashboard_summary.dart @@ -0,0 +1,102 @@ +/// Model representing dashboard summary data +class DashboardSummary { + final double totalRevenue; + final int totalSales; + final double averageSaleValue; + final Map categoryBreakdown; + final List topProducts; + final List salesTrend; + + DashboardSummary({ + required this.totalRevenue, + required this.totalSales, + required this.averageSaleValue, + required this.categoryBreakdown, + required this.topProducts, + required this.salesTrend, + }); + + /// Create DashboardSummary from JSON + factory DashboardSummary.fromJson(Map json) { + return DashboardSummary( + totalRevenue: (json['totalRevenue'] as num).toDouble(), + totalSales: json['totalSales'] as int, + averageSaleValue: (json['averageSaleValue'] as num).toDouble(), + categoryBreakdown: Map.from( + json['categoryBreakdown'] as Map, + ), + topProducts: (json['topProducts'] as List) + .map((item) => TopProduct.fromJson(item as Map)) + .toList(), + salesTrend: (json['salesTrend'] as List) + .map((item) => SalesDataPoint.fromJson(item as Map)) + .toList(), + ); + } + + /// Convert DashboardSummary to JSON + Map toJson() { + return { + 'totalRevenue': totalRevenue, + 'totalSales': totalSales, + 'averageSaleValue': averageSaleValue, + 'categoryBreakdown': categoryBreakdown, + 'topProducts': topProducts.map((p) => p.toJson()).toList(), + 'salesTrend': salesTrend.map((s) => s.toJson()).toList(), + }; + } +} + +/// Model for top selling products +class TopProduct { + final String productName; + final int salesCount; + final double revenue; + + TopProduct({ + required this.productName, + required this.salesCount, + required this.revenue, + }); + + factory TopProduct.fromJson(Map json) { + return TopProduct( + productName: json['productName'] as String, + salesCount: json['salesCount'] as int, + revenue: (json['revenue'] as num).toDouble(), + ); + } + + Map toJson() { + return { + 'productName': productName, + 'salesCount': salesCount, + 'revenue': revenue, + }; + } +} + +/// Model for sales trend data points +class SalesDataPoint { + final DateTime date; + final double amount; + + SalesDataPoint({ + required this.date, + required this.amount, + }); + + factory SalesDataPoint.fromJson(Map json) { + return SalesDataPoint( + date: DateTime.parse(json['date'] as String), + amount: (json['amount'] as num).toDouble(), + ); + } + + Map toJson() { + return { + 'date': date.toIso8601String(), + 'amount': amount, + }; + } +} diff --git a/lib/models/sale_model.dart b/lib/models/sale_model.dart new file mode 100644 index 0000000..cb53b28 --- /dev/null +++ b/lib/models/sale_model.dart @@ -0,0 +1,73 @@ +/// Model representing a sale transaction +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, + }); + + /// Create SaleModel from JSON + 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?, + ); + } + + /// Convert SaleModel to JSON + Map toJson() { + return { + 'id': id, + 'productName': productName, + 'category': category, + 'price': price, + 'quantity': quantity, + 'totalAmount': totalAmount, + 'saleDate': saleDate.toIso8601String(), + 'notes': notes, + }; + } + + /// Create a copy with updated fields + 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..9e32bd5 --- /dev/null +++ b/lib/models/user_model.dart @@ -0,0 +1,61 @@ +/// Model representing a user +class UserModel { + final String id; + final String email; + final String name; + final String? shopName; + final DateTime createdAt; + final String? photoUrl; + + UserModel({ + required this.id, + required this.email, + required this.name, + this.shopName, + required this.createdAt, + this.photoUrl, + }); + + /// Create UserModel from JSON + factory UserModel.fromJson(Map json) { + return UserModel( + id: json['id'] as String, + email: json['email'] as String, + name: json['name'] as String, + shopName: json['shopName'] as String?, + createdAt: DateTime.parse(json['createdAt'] as String), + photoUrl: json['photoUrl'] as String?, + ); + } + + /// Convert UserModel to JSON + Map toJson() { + return { + 'id': id, + 'email': email, + 'name': name, + 'shopName': shopName, + 'createdAt': createdAt.toIso8601String(), + 'photoUrl': photoUrl, + }; + } + + /// Create a copy with updated fields + UserModel copyWith({ + String? id, + String? email, + String? name, + String? shopName, + DateTime? createdAt, + String? photoUrl, + }) { + return UserModel( + id: id ?? this.id, + email: email ?? this.email, + name: name ?? this.name, + shopName: shopName ?? this.shopName, + createdAt: createdAt ?? this.createdAt, + photoUrl: photoUrl ?? this.photoUrl, + ); + } +} diff --git a/lib/repositories/api_insights_repository.dart b/lib/repositories/api_insights_repository.dart new file mode 100644 index 0000000..8c8dbd9 --- /dev/null +++ b/lib/repositories/api_insights_repository.dart @@ -0,0 +1,91 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../core/constants.dart'; +import 'insights_repository.dart'; + +/// API-based implementation of InsightsRepository +class ApiInsightsRepository implements InsightsRepository { + final http.Client _client; + final String _baseUrl; + + ApiInsightsRepository({ + http.Client? client, + String? baseUrl, + }) : _client = client ?? http.Client(), + _baseUrl = baseUrl ?? AppConstants.baseUrl; + + @override + Future getBusinessInsights({ + required DateTime startDate, + required DateTime endDate, + }) async { + final response = await _client.post( + Uri.parse('$_baseUrl/api/insights/business'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'startDate': startDate.toIso8601String(), + 'endDate': endDate.toIso8601String(), + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + return data['insights'] as String; + } else { + throw Exception('Failed to get business insights: ${response.statusCode}'); + } + } + + @override + Future> getProductRecommendations() async { + final response = await _client.get( + Uri.parse('$_baseUrl/api/insights/recommendations'), + headers: {'Content-Type': 'application/json'}, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + return List.from(data['recommendations'] as List); + } else { + throw Exception('Failed to get recommendations: ${response.statusCode}'); + } + } + + @override + Future> getSalesPredictions({ + required int daysAhead, + }) async { + final response = await _client.post( + Uri.parse('$_baseUrl/api/insights/predictions'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'daysAhead': daysAhead}), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body) as Map; + } else { + throw Exception('Failed to get predictions: ${response.statusCode}'); + } + } + + @override + Future> analyzeTrends({ + required DateTime startDate, + required DateTime endDate, + }) async { + final response = await _client.post( + Uri.parse('$_baseUrl/api/insights/trends'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'startDate': startDate.toIso8601String(), + 'endDate': 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..de9df2c --- /dev/null +++ b/lib/repositories/dummy_insights_repository.dart @@ -0,0 +1,98 @@ +import 'insights_repository.dart'; + +/// Dummy implementation of InsightsRepository for testing and development +class DummyInsightsRepository implements InsightsRepository { + @override + Future getBusinessInsights({ + required DateTime startDate, + required DateTime endDate, + }) async { + await Future.delayed(const Duration(seconds: 2)); + + return ''' +Based on your sales data from ${_formatDate(startDate)} to ${_formatDate(endDate)}: + +📈 Key Insights: +• Electronics category shows the highest revenue with steady growth +• Weekend sales are 35% higher than weekday sales +• Average transaction value increased by 12% compared to last period + +💡 Recommendations: +• Stock up on Electronics items for the upcoming weekend +• Consider promotional campaigns for mid-week to boost sales +• Focus on upselling strategies to maintain the increasing transaction value + +🎯 Action Items: +• Monitor inventory levels for top-selling products +• Analyze customer feedback for product improvements +• Plan marketing activities for slower days + '''; + } + + @override + Future> getProductRecommendations() async { + await Future.delayed(const Duration(milliseconds: 1500)); + + return [ + 'Wireless Headphones - High demand in Electronics', + 'Yoga Mat - Trending in Sports category', + 'Smart Watch - Popular accessory', + 'Organic Coffee - Growing Food category trend', + 'LED Desk Lamp - Office essentials category', + ]; + } + + @override + Future> getSalesPredictions({ + required int daysAhead, + }) async { + await Future.delayed(const Duration(seconds: 1)); + + final predictions = { + 'periodDays': daysAhead, + 'predictedRevenue': 5420.50, + 'confidence': 0.85, + 'expectedSales': 127, + 'trends': { + 'Electronics': 'increasing', + 'Clothing': 'stable', + 'Food': 'increasing', + 'Books': 'stable', + }, + }; + + return predictions; + } + + @override + Future> analyzeTrends({ + required DateTime startDate, + required DateTime endDate, + }) async { + await Future.delayed(const Duration(milliseconds: 1800)); + + return { + 'period': { + 'start': startDate.toIso8601String(), + 'end': endDate.toIso8601String(), + }, + 'overallTrend': 'positive', + 'growthRate': 15.5, + 'categoryTrends': { + 'Electronics': {'trend': 'up', 'percentage': 22.3}, + 'Clothing': {'trend': 'stable', 'percentage': 2.1}, + 'Food': {'trend': 'up', 'percentage': 18.7}, + 'Books': {'trend': 'down', 'percentage': -5.2}, + }, + 'insights': [ + 'Electronics showing strong upward trend', + 'Food category gaining momentum', + 'Books category needs attention', + ], + }; + } + + String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/repositories/dummy_sales_repository.dart b/lib/repositories/dummy_sales_repository.dart new file mode 100644 index 0000000..d788add --- /dev/null +++ b/lib/repositories/dummy_sales_repository.dart @@ -0,0 +1,107 @@ +import '../models/sale_model.dart'; +import 'sales_repository.dart'; + +/// Dummy implementation of SalesRepository for testing and development +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: 'High-end gaming laptop', + ), + SaleModel( + id: '2', + productName: 'T-Shirt', + category: 'Clothing', + price: 19.99, + quantity: 5, + totalAmount: 99.95, + saleDate: now.subtract(const Duration(days: 2)), + ), + SaleModel( + id: '3', + productName: 'Coffee Beans', + category: 'Food', + price: 12.50, + quantity: 3, + totalAmount: 37.50, + saleDate: now.subtract(const Duration(days: 3)), + ), + SaleModel( + id: '4', + productName: 'Programming Book', + category: 'Books', + price: 45.00, + quantity: 1, + totalAmount: 45.00, + saleDate: now.subtract(const Duration(days: 4)), + ), + ]); + } + + @override + Future> getAllSales() async { + await Future.delayed(const Duration(milliseconds: 500)); + return List.from(_sales); + } + + @override + Future> getSalesByDateRange(DateTime start, DateTime end) async { + await Future.delayed(const Duration(milliseconds: 500)); + return _sales + .where((sale) => + (sale.saleDate.isAfter(start) || sale.saleDate.isAtSameMomentAs(start)) && + (sale.saleDate.isBefore(end) || sale.saleDate.isAtSameMomentAs(end))) + .toList(); + } + + @override + Future> getSalesByCategory(String category) async { + await Future.delayed(const Duration(milliseconds: 500)); + return _sales.where((sale) => sale.category == category).toList(); + } + + @override + Future addSale(SaleModel sale) async { + await Future.delayed(const Duration(milliseconds: 500)); + _sales.add(sale); + } + + @override + Future updateSale(SaleModel sale) async { + await Future.delayed(const Duration(milliseconds: 500)); + final index = _sales.indexWhere((s) => s.id == sale.id); + if (index != -1) { + _sales[index] = sale; + } + } + + @override + Future deleteSale(String saleId) async { + await Future.delayed(const Duration(milliseconds: 500)); + _sales.removeWhere((sale) => sale.id == saleId); + } + + @override + Future getSaleById(String saleId) async { + await Future.delayed(const Duration(milliseconds: 500)); + try { + return _sales.firstWhere((sale) => sale.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..8b0cf2d --- /dev/null +++ b/lib/repositories/firebase_sales_repository.dart @@ -0,0 +1,83 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import '../models/sale_model.dart'; +import '../core/constants.dart'; +import 'sales_repository.dart'; + +/// Firebase implementation of SalesRepository +class FirebaseSalesRepository implements SalesRepository { + final FirebaseFirestore _firestore; + final String _userId; + + FirebaseSalesRepository({ + FirebaseFirestore? firestore, + required String userId, + }) : _firestore = firestore ?? FirebaseFirestore.instance, + _userId = userId; + + CollectionReference get _salesCollection => + _firestore + .collection('users') + .doc(_userId) + .collection(AppConstants.firebaseCollectionSales); + + @override + Future> getAllSales() async { + final snapshot = await _salesCollection.orderBy('saleDate', descending: true).get(); + return snapshot.docs + .map((doc) => SaleModel.fromJson({...doc.data() as Map, 'id': doc.id})) + .toList(); + } + + @override + Future> getSalesByDateRange(DateTime start, DateTime end) async { + final snapshot = await _salesCollection + .where('saleDate', isGreaterThanOrEqualTo: start.toIso8601String()) + .where('saleDate', isLessThanOrEqualTo: end.toIso8601String()) + .orderBy('saleDate', descending: true) + .get(); + + return snapshot.docs + .map((doc) => SaleModel.fromJson({...doc.data() as Map, 'id': doc.id})) + .toList(); + } + + @override + Future> getSalesByCategory(String category) async { + final snapshot = await _salesCollection + .where('category', isEqualTo: category) + .orderBy('saleDate', descending: true) + .get(); + + return snapshot.docs + .map((doc) => SaleModel.fromJson({...doc.data() as Map, 'id': doc.id})) + .toList(); + } + + @override + Future addSale(SaleModel sale) async { + final data = sale.toJson(); + data.remove('id'); + await _salesCollection.add(data); + } + + @override + Future updateSale(SaleModel sale) async { + final data = sale.toJson(); + data.remove('id'); + await _salesCollection.doc(sale.id).update(data); + } + + @override + Future deleteSale(String saleId) async { + await _salesCollection.doc(saleId).delete(); + } + + @override + Future getSaleById(String saleId) async { + final doc = await _salesCollection.doc(saleId).get(); + if (!doc.exists) { + return null; + } + return SaleModel.fromJson({...doc.data() as Map, 'id': doc.id}); + } +} diff --git a/lib/repositories/insights_repository.dart b/lib/repositories/insights_repository.dart new file mode 100644 index 0000000..c5dc960 --- /dev/null +++ b/lib/repositories/insights_repository.dart @@ -0,0 +1,22 @@ +/// Abstract repository interface for AI-powered insights +abstract class InsightsRepository { + /// Get business insights based on sales data + Future getBusinessInsights({ + required DateTime startDate, + required DateTime endDate, + }); + + /// Get product recommendations + Future> getProductRecommendations(); + + /// Get sales predictions for the next period + Future> getSalesPredictions({ + required int daysAhead, + }); + + /// Analyze trends in sales data + 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..9382d73 --- /dev/null +++ b/lib/repositories/sales_repository.dart @@ -0,0 +1,25 @@ +import '../models/sale_model.dart'; + +/// Abstract repository interface for sales data +abstract class SalesRepository { + /// Get all sales + Future> getAllSales(); + + /// Get sales within a date range + Future> getSalesByDateRange(DateTime start, DateTime end); + + /// Get sales by category + Future> getSalesByCategory(String category); + + /// Add a new sale + Future addSale(SaleModel sale); + + /// Update an existing sale + Future updateSale(SaleModel sale); + + /// Delete a sale + Future deleteSale(String saleId); + + /// Get a single sale by ID + 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..730c6dd --- /dev/null +++ b/lib/screens/add_sale_screen.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import '../models/sale_model.dart'; +import '../repositories/dummy_sales_repository.dart'; +import '../core/constants.dart'; + +/// Screen for adding a new sale +class AddSaleScreen extends StatefulWidget { + const AddSaleScreen({super.key}); + + @override + State createState() => _AddSaleScreenState(); +} + +class _AddSaleScreenState extends State { + final _formKey = GlobalKey(); + final _productNameController = TextEditingController(); + final _priceController = TextEditingController(); + final _quantityController = TextEditingController(); + final _notesController = TextEditingController(); + final _salesRepository = DummySalesRepository(); + + String _selectedCategory = AppConstants.productCategories.first; + DateTime _selectedDate = DateTime.now(); + bool _isLoading = false; + + @override + void dispose() { + _productNameController.dispose(); + _priceController.dispose(); + _quantityController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + Future _handleSaveSale() 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.trim(), + category: _selectedCategory, + price: price, + quantity: quantity, + totalAmount: totalAmount, + saleDate: _selectedDate, + notes: _notesController.text.trim().isEmpty + ? null + : _notesController.text.trim(), + ); + + 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('Failed to add sale: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _selectDate() async { + final pickedDate = await showDatePicker( + context: context, + initialDate: _selectedDate, + firstDate: DateTime(2020), + lastDate: DateTime.now(), + ); + + if (pickedDate != null) { + setState(() { + _selectedDate = pickedDate; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Add Sale'), + actions: [ + IconButton( + icon: const Icon(Icons.camera_alt), + onPressed: () { + // Navigate to OCR screen + Navigator.of(context).pushNamed('/ocr'); + }, + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextFormField( + controller: _productNameController, + decoration: const InputDecoration( + labelText: 'Product Name', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.shopping_bag), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter product name'; + } + return null; + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _selectedCategory, + decoration: const InputDecoration( + labelText: 'Category', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.category), + ), + items: AppConstants.productCategories + .map((category) => DropdownMenuItem( + value: category, + child: Text(category), + )) + .toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedCategory = value; + }); + } + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _priceController, + decoration: const InputDecoration( + labelText: 'Price', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.attach_money), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + 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), + ListTile( + title: const Text('Sale Date'), + subtitle: Text( + '${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}', + ), + leading: const Icon(Icons.calendar_today), + trailing: const Icon(Icons.arrow_forward_ios), + onTap: _selectDate, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: Colors.grey.shade400), + ), + ), + 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 : _handleSaveSale, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save Sale'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/ai_insight_screen.dart b/lib/screens/ai_insight_screen.dart new file mode 100644 index 0000000..9acb725 --- /dev/null +++ b/lib/screens/ai_insight_screen.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import '../repositories/dummy_insights_repository.dart'; +import '../widgets/insight_card.dart'; + +/// Screen for AI-powered insights +class AIInsightScreen extends StatefulWidget { + const AIInsightScreen({super.key}); + + @override + State createState() => _AIInsightScreenState(); +} + +class _AIInsightScreenState extends State { + final _insightsRepository = DummyInsightsRepository(); + String? _businessInsights; + List? _recommendations; + Map? _predictions; + Map? _trends; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadInsights(); + } + + Future _loadInsights() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final endDate = DateTime.now(); + final startDate = endDate.subtract(const Duration(days: 30)); + + final results = await Future.wait([ + _insightsRepository.getBusinessInsights( + startDate: startDate, + endDate: endDate, + ), + _insightsRepository.getProductRecommendations(), + _insightsRepository.getSalesPredictions(daysAhead: 7), + _insightsRepository.analyzeTrends( + startDate: startDate, + endDate: endDate, + ), + ]); + + setState(() { + _businessInsights = results[0] as String; + _recommendations = results[1] as List; + _predictions = results[2] as Map; + _trends = results[3] as Map; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('AI Insights'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadInsights, + ), + ], + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Analyzing your data with AI...'), + ], + ), + ); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error, size: 64, color: Colors.red), + const SizedBox(height: 16), + Text('Error: $_error'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadInsights, + child: const Text('Retry'), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: _loadInsights, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (_businessInsights != null) + InsightCard( + title: 'Business Insights', + icon: Icons.insights, + color: Colors.blue, + child: Text(_businessInsights!), + ), + const SizedBox(height: 16), + if (_recommendations != null && _recommendations!.isNotEmpty) + InsightCard( + title: 'Product Recommendations', + icon: Icons.recommend, + color: Colors.green, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _recommendations! + .map((rec) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.check_circle, + size: 20, color: Colors.green), + const SizedBox(width: 8), + Expanded(child: Text(rec)), + ], + ), + )) + .toList(), + ), + ), + const SizedBox(height: 16), + if (_predictions != null) + InsightCard( + title: 'Sales Predictions', + icon: Icons.trending_up, + color: Colors.orange, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Next ${_predictions!['periodDays']} days:', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text('Predicted Revenue: \$${_predictions!['predictedRevenue']}'), + Text('Expected Sales: ${_predictions!['expectedSales']}'), + Text('Confidence: ${(_predictions!['confidence'] * 100).toStringAsFixed(0)}%'), + ], + ), + ), + const SizedBox(height: 16), + if (_trends != null) + InsightCard( + title: 'Trend Analysis', + icon: Icons.analytics, + color: Colors.purple, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Overall Trend: ${_trends!['overallTrend']}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Text('Growth Rate: ${_trends!['growthRate']}%'), + const SizedBox(height: 12), + const Text( + 'Key Insights:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ...(_trends!['insights'] as List).map( + (insight) => Padding( + padding: const EdgeInsets.only(top: 4), + child: Text('• $insight'), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart new file mode 100644 index 0000000..e75df3f --- /dev/null +++ b/lib/screens/dashboard_screen.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import '../models/dashboard_summary.dart'; +import '../services/dashboard_service.dart'; +import '../repositories/dummy_sales_repository.dart'; +import '../widgets/sales_chart.dart'; +import '../widgets/category_bar_chart.dart'; +import '../widgets/top_products_list.dart'; +import '../core/app_router.dart'; + +/// Dashboard screen showing sales analytics +class DashboardScreen extends StatefulWidget { + const DashboardScreen({super.key}); + + @override + State createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends State { + late final DashboardService _dashboardService; + DashboardSummary? _summary; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _dashboardService = DashboardService( + salesRepository: DummySalesRepository(), + ); + _loadDashboardData(); + } + + Future _loadDashboardData() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final endDate = DateTime.now(); + final startDate = endDate.subtract(const Duration(days: 30)); + + final summary = await _dashboardService.getDashboardSummary( + startDate: startDate, + endDate: endDate, + ); + + setState(() { + _summary = summary; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Dashboard'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadDashboardData, + ), + IconButton( + icon: const Icon(Icons.lightbulb), + onPressed: () { + Navigator.of(context).pushNamed(AppRouter.aiInsight); + }, + ), + ], + ), + body: _buildBody(), + floatingActionButton: FloatingActionButton( + onPressed: () { + Navigator.of(context).pushNamed(AppRouter.addSale); + }, + child: const Icon(Icons.add), + ), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error, size: 64, color: Colors.red), + const SizedBox(height: 16), + Text('Error: $_error'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadDashboardData, + child: const Text('Retry'), + ), + ], + ), + ); + } + + if (_summary == null) { + return const Center(child: Text('No data available')); + } + + return RefreshIndicator( + onRefresh: _loadDashboardData, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildSummaryCards(), + const SizedBox(height: 24), + _buildSalesChart(), + const SizedBox(height: 24), + _buildCategoryChart(), + const SizedBox(height: 24), + _buildTopProducts(), + ], + ), + ); + } + + 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), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color, size: 32), + const SizedBox(height: 8), + Text( + title, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 4), + Text( + value, + style: Theme.of(context).textTheme.headlineSmall, + ), + ], + ), + ), + ); + } + + Widget _buildSalesChart() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sales Trend', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + SalesChart(salesData: _summary!.salesTrend), + ], + ), + ), + ); + } + + Widget _buildCategoryChart() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sales by Category', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + CategoryBarChart(categoryData: _summary!.categoryBreakdown), + ], + ), + ), + ); + } + + Widget _buildTopProducts() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Top Products', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TopProductsList(products: _summary!.topProducts), + ], + ), + ), + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..972193d --- /dev/null +++ b/lib/screens/login_screen.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import '../services/auth_service.dart'; +import '../core/app_router.dart'; + +/// Login screen for user authentication +class LoginScreen extends StatefulWidget { + const LoginScreen({super.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.signInWithEmailAndPassword( + email: _emailController.text.trim(), + password: _passwordController.text, + ); + + if (mounted) { + Navigator.of(context).pushReplacementNamed(AppRouter.dashboard); + } + } 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: SafeArea( + child: 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.store, + size: 80, + color: Colors.blue, + ), + const SizedBox(height: 24), + Text( + 'Shop Insights', + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Maximize your profit with AI insights', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + 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'), + ), + const SizedBox(height: 16), + TextButton( + onPressed: () { + // Navigate to sign up (not implemented) + }, + child: const Text('Don\'t have an account? Sign up'), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/ocr_screen.dart b/lib/screens/ocr_screen.dart new file mode 100644 index 0000000..8adcedf --- /dev/null +++ b/lib/screens/ocr_screen.dart @@ -0,0 +1,204 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import '../services/ocr_service.dart'; + +/// Screen for OCR (receipt scanning) +class OCRScreen extends StatefulWidget { + const OCRScreen({super.key}); + + @override + State createState() => _OCRScreenState(); +} + +class _OCRScreenState extends State { + final _ocrService = OCRService(); + File? _imageFile; + String? _extractedText; + Map? _parsedData; + bool _isProcessing = false; + + Future _pickImageFromGallery() async { + setState(() { + _isProcessing = true; + _extractedText = null; + _parsedData = null; + }); + + try { + final image = await _ocrService.pickImageFromGallery(); + if (image != null) { + setState(() { + _imageFile = image; + }); + await _processImage(image); + } + } catch (e) { + _showError('Failed to pick image: $e'); + } finally { + setState(() { + _isProcessing = false; + }); + } + } + + Future _pickImageFromCamera() async { + setState(() { + _isProcessing = true; + _extractedText = null; + _parsedData = null; + }); + + try { + final image = await _ocrService.pickImageFromCamera(); + if (image != null) { + setState(() { + _imageFile = image; + }); + await _processImage(image); + } + } catch (e) { + _showError('Failed to capture image: $e'); + } finally { + setState(() { + _isProcessing = false; + }); + } + } + + Future _processImage(File image) async { + try { + final text = await _ocrService.extractTextFromImage(image); + final parsed = _ocrService.parseReceiptData(text); + + setState(() { + _extractedText = text; + _parsedData = parsed; + }); + } catch (e) { + _showError('Failed to process image: $e'); + } + } + + void _showError(String message) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + } + + @override + void dispose() { + _ocrService.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Scan Receipt'), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const Text( + 'Choose an option to scan receipt', + style: TextStyle(fontSize: 16), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton.icon( + onPressed: _isProcessing ? null : _pickImageFromCamera, + icon: const Icon(Icons.camera_alt), + label: const Text('Camera'), + ), + ElevatedButton.icon( + onPressed: _isProcessing ? null : _pickImageFromGallery, + icon: const Icon(Icons.photo_library), + label: const Text('Gallery'), + ), + ], + ), + ], + ), + ), + ), + if (_isProcessing) ...[ + const SizedBox(height: 24), + const Center( + child: CircularProgressIndicator(), + ), + const SizedBox(height: 16), + const Center( + child: Text('Processing image...'), + ), + ], + if (_imageFile != null && !_isProcessing) ...[ + const SizedBox(height: 24), + Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Image.file(_imageFile!, height: 300, fit: BoxFit.cover), + if (_extractedText != null) + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Extracted Text:', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text(_extractedText!), + ], + ), + ), + ], + ), + ), + ], + if (_parsedData != null) ...[ + const SizedBox(height: 24), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Parsed Data:', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + Text('Total: \$${_parsedData!['total']}'), + const SizedBox(height: 8), + Text('Items: ${(_parsedData!['items'] as List).length}'), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () { + // Navigate to add sale with pre-filled data + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.add), + label: const Text('Create Sale from Data'), + ), + ], + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart new file mode 100644 index 0000000..4c8dea9 --- /dev/null +++ b/lib/services/auth_service.dart @@ -0,0 +1,114 @@ +import 'package:firebase_auth/firebase_auth.dart'; +import '../models/user_model.dart'; + +/// Service for authentication operations +class AuthService { + final FirebaseAuth _firebaseAuth; + + AuthService({FirebaseAuth? firebaseAuth}) + : _firebaseAuth = firebaseAuth ?? FirebaseAuth.instance; + + /// Get current user + User? get currentUser => _firebaseAuth.currentUser; + + /// Stream of auth state changes + Stream get authStateChanges => _firebaseAuth.authStateChanges(); + + /// Sign in with email and password + Future signInWithEmailAndPassword({ + required String email, + required String password, + }) async { + try { + final credential = await _firebaseAuth.signInWithEmailAndPassword( + email: email, + password: password, + ); + + if (credential.user == null) { + throw Exception('Sign in failed'); + } + + return _userFromFirebase(credential.user!); + } on FirebaseAuthException catch (e) { + throw _handleAuthException(e); + } + } + + /// Sign up with email and password + Future signUpWithEmailAndPassword({ + required String email, + required String password, + required String name, + String? shopName, + }) async { + try { + final credential = await _firebaseAuth.createUserWithEmailAndPassword( + email: email, + password: password, + ); + + if (credential.user == null) { + throw Exception('Sign up failed'); + } + + // Update display name + await credential.user!.updateDisplayName(name); + + return _userFromFirebase(credential.user!, name: name, shopName: shopName); + } on FirebaseAuthException catch (e) { + throw _handleAuthException(e); + } + } + + /// Sign out + Future signOut() async { + await _firebaseAuth.signOut(); + } + + /// Reset password + Future resetPassword(String email) async { + try { + await _firebaseAuth.sendPasswordResetEmail(email: email); + } on FirebaseAuthException catch (e) { + throw _handleAuthException(e); + } + } + + /// Convert Firebase User to UserModel + UserModel _userFromFirebase(User user, {String? name, String? shopName}) { + return UserModel( + id: user.uid, + email: user.email ?? '', + name: name ?? user.displayName ?? 'User', + shopName: shopName, + createdAt: user.metadata.creationTime ?? DateTime.now(), + photoUrl: user.photoURL, + ); + } + + /// Handle Firebase Auth exceptions + String _handleAuthException(FirebaseAuthException e) { + switch (e.code) { + case 'user-not-found': + return 'No user found with this email.'; + case 'wrong-password': + return 'Wrong password provided.'; + case 'email-already-in-use': + return 'An account already exists with this email.'; + case 'weak-password': + return 'Password is too weak.'; + case 'invalid-email': + return 'Invalid email address.'; + default: + return 'Authentication error: ${e.message}'; + } + } + + /// Get current user as UserModel + Future getCurrentUserModel() async { + final user = currentUser; + if (user == null) return null; + return _userFromFirebase(user); + } +} diff --git a/lib/services/dashboard_service.dart b/lib/services/dashboard_service.dart new file mode 100644 index 0000000..99dcd1b --- /dev/null +++ b/lib/services/dashboard_service.dart @@ -0,0 +1,98 @@ +import '../models/sale_model.dart'; +import '../models/dashboard_summary.dart'; +import '../repositories/sales_repository.dart'; + +/// Service for dashboard-related operations +class DashboardService { + final SalesRepository _salesRepository; + + DashboardService({required SalesRepository salesRepository}) + : _salesRepository = salesRepository; + + /// Get dashboard summary for a date range + Future getDashboardSummary({ + required DateTime startDate, + required DateTime endDate, + }) async { + final sales = await _salesRepository.getSalesByDateRange(startDate, endDate); + + // Calculate total revenue + final totalRevenue = sales.fold( + 0.0, + (sum, sale) => sum + sale.totalAmount, + ); + + // Calculate total sales count + final totalSales = sales.length; + + // Calculate average sale value + final averageSaleValue = totalSales > 0 ? totalRevenue / totalSales : 0.0; + + // Category breakdown + final categoryBreakdown = {}; + for (final sale in sales) { + categoryBreakdown[sale.category] = + (categoryBreakdown[sale.category] ?? 0.0) + sale.totalAmount; + } + + // Top products + final productSales = {}; + for (final sale in sales) { + if (productSales.containsKey(sale.productName)) { + final existing = productSales[sale.productName]!; + productSales[sale.productName] = TopProduct( + productName: sale.productName, + salesCount: existing.salesCount + sale.quantity, + revenue: existing.revenue + sale.totalAmount, + ); + } else { + productSales[sale.productName] = TopProduct( + productName: sale.productName, + salesCount: sale.quantity, + revenue: sale.totalAmount, + ); + } + } + + final topProducts = productSales.values.toList() + ..sort((a, b) => b.revenue.compareTo(a.revenue)); + + // Sales trend (daily aggregation) + final salesByDate = {}; + for (final sale in sales) { + final dateOnly = DateTime( + sale.saleDate.year, + sale.saleDate.month, + sale.saleDate.day, + ); + salesByDate[dateOnly] = (salesByDate[dateOnly] ?? 0.0) + sale.totalAmount; + } + + final salesTrend = salesByDate.entries + .map((entry) => SalesDataPoint(date: entry.key, amount: entry.value)) + .toList() + ..sort((a, b) => a.date.compareTo(b.date)); + + return DashboardSummary( + totalRevenue: totalRevenue, + totalSales: totalSales, + averageSaleValue: averageSaleValue, + categoryBreakdown: categoryBreakdown, + topProducts: topProducts.take(10).toList(), + salesTrend: salesTrend, + ); + } + + /// Get sales data for a specific period + Future> getSales({ + required DateTime startDate, + required DateTime endDate, + }) async { + return await _salesRepository.getSalesByDateRange(startDate, endDate); + } + + /// Get all sales + Future> getAllSales() async { + return await _salesRepository.getAllSales(); + } +} diff --git a/lib/services/ocr_service.dart b/lib/services/ocr_service.dart new file mode 100644 index 0000000..6d3b3a2 --- /dev/null +++ b/lib/services/ocr_service.dart @@ -0,0 +1,82 @@ +import 'dart:io'; +import 'package:google_ml_kit/google_ml_kit.dart'; +import 'package:image_picker/image_picker.dart'; + +/// Service for OCR (Optical Character Recognition) operations +class OCRService { + final TextRecognizer _textRecognizer; + final ImagePicker _imagePicker; + + OCRService({ + TextRecognizer? textRecognizer, + ImagePicker? imagePicker, + }) : _textRecognizer = textRecognizer ?? GoogleMlKit.vision.textRecognizer(), + _imagePicker = imagePicker ?? ImagePicker(); + + /// Pick an image from gallery + Future pickImageFromGallery() async { + final XFile? pickedFile = await _imagePicker.pickImage( + source: ImageSource.gallery, + ); + + if (pickedFile != null) { + return File(pickedFile.path); + } + return null; + } + + /// Pick an image from camera + Future pickImageFromCamera() async { + final XFile? pickedFile = await _imagePicker.pickImage( + source: ImageSource.camera, + ); + + if (pickedFile != null) { + return File(pickedFile.path); + } + return null; + } + + /// Extract text from an image file + Future extractTextFromImage(File imageFile) async { + final inputImage = InputImage.fromFile(imageFile); + final RecognizedText recognizedText = await _textRecognizer.processImage(inputImage); + + return recognizedText.text; + } + + /// Parse receipt data from extracted text + Map parseReceiptData(String text) { + final lines = text.split('\n'); + final items = >[]; + double total = 0.0; + + // More specific pattern for prices - requires decimal point or currency symbol + for (final line in lines) { + // Look for price patterns with currency symbol or decimal format + final priceMatch = RegExp(r'(?:\$|USD|€|£)?\s*(\d+\.\d{2})\b').firstMatch(line); + if (priceMatch != null) { + final price = double.tryParse(priceMatch.group(1) ?? '0'); + if (price != null && price > 0) { + items.add({ + 'description': line.replaceAll(priceMatch.group(0)!, '').trim(), + 'amount': price, + }); + total += price; + } + } + } + + return { + 'items': items, + 'total': total, + 'rawText': text, + 'timestamp': DateTime.now().toIso8601String(), + }; + } + + /// Clean up resources + void dispose() { + _textRecognizer.close(); + } +} diff --git a/lib/utils/parsers.dart b/lib/utils/parsers.dart new file mode 100644 index 0000000..79d11e4 --- /dev/null +++ b/lib/utils/parsers.dart @@ -0,0 +1,113 @@ +import '../models/sale_model.dart'; + +/// Utility functions for parsing data +class Parsers { + /// Parse sale data from CSV format + static List parseSalesFromCSV(String csvData) { + final lines = csvData.split('\n'); + final sales = []; + + // Skip header line + for (int i = 1; i < lines.length; i++) { + final line = lines[i].trim(); + if (line.isEmpty) continue; + + final parts = line.split(','); + if (parts.length >= 7) { + try { + sales.add(SaleModel( + id: parts[0], + productName: parts[1], + category: parts[2], + price: double.parse(parts[3]), + quantity: int.parse(parts[4]), + totalAmount: double.parse(parts[5]), + saleDate: DateTime.parse(parts[6]), + notes: parts.length > 7 ? parts[7] : null, + )); + } catch (e) { + // Skip invalid lines + continue; + } + } + } + + return sales; + } + + /// Convert sales list to CSV format + static String salesToCSV(List sales) { + final buffer = StringBuffer(); + buffer.writeln('id,productName,category,price,quantity,totalAmount,saleDate,notes'); + + for (final sale in sales) { + buffer.writeln( + '${sale.id},${sale.productName},${sale.category},' + '${sale.price},${sale.quantity},${sale.totalAmount},' + '${sale.saleDate.toIso8601String()},${sale.notes ?? ''}', + ); + } + + return buffer.toString(); + } + + /// Parse receipt text to extract items and prices + static Map parseReceiptText(String text) { + final lines = text.split('\n'); + final items = >[]; + double total = 0.0; + + // More restrictive pattern for prices - requires currency symbol or decimal point + final pricePattern = RegExp(r'(?:\$|USD|€|£)?\s*(\d+\.\d{2})\b'); + + for (final line in lines) { + final trimmedLine = line.trim(); + if (trimmedLine.isEmpty) continue; + + // Try to extract price from line + final matches = pricePattern.allMatches(trimmedLine); + for (final match in matches) { + final priceStr = match.group(1); + if (priceStr != null) { + final price = double.tryParse(priceStr); + if (price != null && price > 0) { + // Extract item description (text before the price) + final description = trimmedLine + .substring(0, match.start) + .trim() + .replaceAll(RegExp(r'[^\w\s]'), ''); + + if (description.isNotEmpty) { + items.add({ + 'description': description, + 'price': price, + }); + total += price; + } + } + } + } + } + + return { + 'items': items, + 'total': total, + 'itemCount': items.length, + }; + } + + /// Format currency value + static String formatCurrency(double value, {String symbol = '\$'}) { + return '$symbol${value.toStringAsFixed(2)}'; + } + + /// Format date to readable string + static String formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + /// Format date with time + static String formatDateTime(DateTime date) { + return '${formatDate(date)} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/widgets/category_bar_chart.dart b/lib/widgets/category_bar_chart.dart new file mode 100644 index 0000000..84a62ad --- /dev/null +++ b/lib/widgets/category_bar_chart.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; + +/// Widget to display category sales as a bar chart +class CategoryBarChart extends StatelessWidget { + final Map categoryData; + + const CategoryBarChart({ + super.key, + required this.categoryData, + }); + + @override + Widget build(BuildContext context) { + if (categoryData.isEmpty) { + return const SizedBox( + height: 200, + child: Center(child: Text('No category data available')), + ); + } + + final sortedEntries = categoryData.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + + return SizedBox( + height: 200, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: sortedEntries.first.value * 1.2, + barTouchData: BarTouchData( + enabled: true, + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final category = sortedEntries[group.x.toInt()].key; + return BarTooltipItem( + '$category\n\$${rod.toY.toStringAsFixed(2)}', + const TextStyle(color: Colors.white), + ); + }, + ), + ), + titlesData: FlTitlesData( + show: true, + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: (value, meta) { + if (value.toInt() >= 0 && value.toInt() < sortedEntries.length) { + final category = sortedEntries[value.toInt()].key; + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + category.length > 8 + ? '${category.substring(0, 8)}...' + : category, + style: const TextStyle(fontSize: 10), + ), + ); + } + return const Text(''); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: (value, meta) { + return Text( + '\$${value.toInt()}', + style: const TextStyle(fontSize: 10), + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + borderData: FlBorderData(show: false), + barGroups: sortedEntries.asMap().entries.map((entry) { + return BarChartGroupData( + x: entry.key, + barRods: [ + BarChartRodData( + toY: entry.value.value, + color: Colors.primaries[entry.key % Colors.primaries.length], + width: 20, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4), + ), + ), + ], + ); + }).toList(), + ), + ), + ); + } +} diff --git a/lib/widgets/insight_card.dart b/lib/widgets/insight_card.dart new file mode 100644 index 0000000..f4d7a76 --- /dev/null +++ b/lib/widgets/insight_card.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +/// Card widget for displaying insights +class InsightCard extends StatelessWidget { + final String title; + final IconData icon; + final Color color; + final Widget child; + + const InsightCard({ + super.key, + required this.title, + required this.icon, + required this.color, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 28), + const SizedBox(width: 12), + Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: color, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + child, + ], + ), + ), + ); + } +} diff --git a/lib/widgets/sales_chart.dart b/lib/widgets/sales_chart.dart new file mode 100644 index 0000000..9773317 --- /dev/null +++ b/lib/widgets/sales_chart.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import '../models/dashboard_summary.dart'; + +/// Widget to display sales trend chart +class SalesChart extends StatelessWidget { + final List salesData; + + const SalesChart({ + super.key, + required this.salesData, + }); + + @override + Widget build(BuildContext context) { + if (salesData.isEmpty) { + return const SizedBox( + height: 200, + child: Center(child: Text('No sales data available')), + ); + } + + return SizedBox( + height: 200, + child: LineChart( + LineChartData( + gridData: FlGridData(show: true), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: (value, meta) { + return Text( + '\$${value.toInt()}', + style: const TextStyle(fontSize: 10), + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + getTitlesWidget: (value, meta) { + if (value.toInt() >= 0 && value.toInt() < salesData.length) { + final date = salesData[value.toInt()].date; + return Text( + '${date.month}/${date.day}', + style: const TextStyle(fontSize: 10), + ); + } + return const Text(''); + }, + ), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + borderData: FlBorderData(show: true), + lineBarsData: [ + LineChartBarData( + spots: salesData.asMap().entries.map((entry) { + return FlSpot( + entry.key.toDouble(), + entry.value.amount, + ); + }).toList(), + isCurved: true, + color: Colors.blue, + barWidth: 3, + dotData: const FlDotData(show: true), + belowBarData: BarAreaData( + show: true, + color: Colors.blue.withOpacity(0.3), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/top_products_list.dart b/lib/widgets/top_products_list.dart new file mode 100644 index 0000000..16a28dc --- /dev/null +++ b/lib/widgets/top_products_list.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import '../models/dashboard_summary.dart'; + +/// Widget to display top selling products +class TopProductsList extends StatelessWidget { + final List products; + + const TopProductsList({ + super.key, + required this.products, + }); + + @override + Widget build(BuildContext context) { + if (products.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: Text('No products data available'), + ), + ); + } + + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: products.length, + itemBuilder: (context, index) { + final product = products[index]; + return ListTile( + leading: CircleAvatar( + backgroundColor: Colors.blue, + child: Text( + '${index + 1}', + style: const TextStyle(color: Colors.white), + ), + ), + title: Text( + product.productName, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text('${product.salesCount} units sold'), + trailing: Text( + '\$${product.revenue.toStringAsFixed(2)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ); + }, + ); + } +}