Product Add Done
This commit is contained in:
71
kifi-app/APP_FEATURES.md
Normal file
71
kifi-app/APP_FEATURES.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Kifi Application Context & Features Documentation
|
||||
|
||||
## Overview
|
||||
Kifi is a comprehensive personal finance, expense tracking, and wealth management mobile application built using Flutter. It is designed to track daily expenses, manage budgets, track assets and liabilities, and facilitate collaborative finance through shared wallets.
|
||||
|
||||
## Tech Stack
|
||||
- **Frontend Framework**: Flutter / Dart
|
||||
- **State Management**: Riverpod (Providers)
|
||||
- **Networking**: Dio (HTTP client for REST API communication)
|
||||
- **Backend Infrastructure**: REST APIs connected to a PostgreSQL database
|
||||
- **Local Storage**: SharedPreferences for session caching
|
||||
|
||||
## Application Architecture (Feature-Driven Design)
|
||||
The application is structured into domain-specific features inside `lib/features/`:
|
||||
|
||||
### 1. Authentication (`lib/features/auth`)
|
||||
- **Login/Registration**: Phone number or email-based login with OTP verification.
|
||||
- **Session Management**: JWT token-based authentication stored securely on the device.
|
||||
- **Profile Management**: User profile handling and device tracking.
|
||||
|
||||
### 2. Dashboard (`lib/features/dashboard`)
|
||||
- **Main Hub**: The central screen incorporating a Bottom Navigation Bar (`IndexedStack` for state preservation).
|
||||
- **Navigation Tabs**:
|
||||
1. Home (Dashboard summary)
|
||||
2. Stats (Analytics & Charts)
|
||||
3. Add (Floating middle button for quick Transaction creation)
|
||||
4. Wallets (Account management)
|
||||
5. Budgets (Limit management)
|
||||
- **Financial Summary Cards**: High-level aggregated balances grouped by financial "Nature" (e.g., Expense, Income, Cash, Bank, Assets, Investments, Receivables, Payables, Liabilities).
|
||||
- **Maturity Alerts**: Dialog prompts to close or settle matured investments and receivables.
|
||||
|
||||
### 3. Wallets & Accounts Management (`lib/features/dashboard/presentation/accounts_screen.dart`)
|
||||
- **Wallet Natures**: Wallets are strictly categorized by their nature (`CASH`, `BANK`, `INVESTMENTS`, `LIABILITIES`, etc.).
|
||||
- **Ledger System**: Each wallet maintains its own transactional ledger reflecting debit/credit logic based on its nature.
|
||||
- **Shared Wallets (Collaboration)**:
|
||||
- Users can invite others to co-manage a wallet (via email/phone).
|
||||
- Transactions, ledgers, and budgets for shared wallets are seamlessly aggregated across all members.
|
||||
- **UI Elements**: Persistent search bars and robust nature-based filtering using modern bottom-sheets.
|
||||
|
||||
### 4. Transactions (`lib/features/transactions`)
|
||||
- **Transaction Types**: Expense, Income, and internal Transfers.
|
||||
- **Data Capture**: Records amount, date, source/destination wallet, category, and optional notes.
|
||||
- **Attachments**: Supports image uploads for receipts. Includes an `AttachmentGalleryScreen` allowing swipe-to-scroll, zoom-on-double-tap, and pinch-to-zoom gestures.
|
||||
- **Filters & Search**: Transactions can be searched by text and filtered by type, wallet, and category.
|
||||
- **Data Handling**: Uses paginated API calls (infinite scrolling) for fetching large transaction histories.
|
||||
|
||||
### 5. Budgets (`lib/features/budget`)
|
||||
- **Account-Level Budgeting**: Budgets are explicitly mapped to individual wallets/accounts.
|
||||
- **Shared Budgets**: If a wallet is shared, the budget cap applies globally to all members contributing to that wallet.
|
||||
- **Progress Tracking**: Visual progress bars showing current spending versus the set limit.
|
||||
- **Consistency**: UI matches the robust search and filter styling of the Transactions and Accounts screens.
|
||||
|
||||
### 6. Statistics & Analytics
|
||||
- **Visualizations**:
|
||||
- **Pie Charts**: Breakdown of spending grouped by category.
|
||||
- **Bar Charts**: Day-wise and trend-based spending over time.
|
||||
- **Custom Date Filtering**: Data can be instantly recalculated for predefined ranges (Today, This Week, This Month) or Custom Date selections.
|
||||
|
||||
### 7. Notifications & Onboarding
|
||||
- **Onboarding Flow**: Welcomes new users and sets up initial data (`lib/features/onboarding`).
|
||||
- **Alerts**: Handles pending wallet invitations and potentially budget threshold warnings.
|
||||
|
||||
## UI/UX Design System
|
||||
- **Colors**: Implements a `NatureColors` scheme mapping specific financial natures to distinct, consistent colors across the app (e.g., Expenses are red, Incomes are green, Assets are blue).
|
||||
- **Standardized Components**: Utilizes highly consistent bottom sheets for all filtering actions (`TransactionFilterSheet`, etc.) and unified rounded gray `TextField` designs for searching.
|
||||
- **Gestures**: Focuses on mobile-first interactions, including swipeable image galleries and pull-to-refresh lists.
|
||||
|
||||
## Key Developer & AI Notes
|
||||
- **Code Patterns**: The app makes heavy use of Riverpod `ConsumerStatefulWidget`, `ref.watch`, and `.when()` for async data states (`loading`, `error`, `data`).
|
||||
- **API Interactions**: Complex data aggregation (like dashboard totals) often relies on the backend returning pre-calculated structures, while frontend filtering works locally on cached states.
|
||||
- **Navigation**: Employs standard Flutter `Navigator` (`MaterialPageRoute`) for deep-linking (e.g., from a dashboard card directly into a filtered Accounts screen).
|
||||
@@ -49,6 +49,11 @@
|
||||
<string>Used to select receipts for auto-filling transaction details.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Authenticate to access your personal finance data securely.</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@@ -15,7 +15,8 @@ class DioClient {
|
||||
|
||||
DioClient._internal()
|
||||
: dio = Dio(BaseOptions(
|
||||
baseUrl: 'https://app.technobeesolutions.in/api/kifi',
|
||||
baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2',
|
||||
//baseUrl: 'http://192.168.0.104:8080/api/kifi-v2', // Local Mac IP for physical device testing
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
)),
|
||||
|
||||
@@ -12,6 +12,8 @@ import '../providers/auth_provider.dart';
|
||||
import '../../transactions/providers/providers.dart';
|
||||
|
||||
import '../../../core/theme/theme_provider.dart';
|
||||
import '../../business/providers/business_mode_provider.dart';
|
||||
import '../../business/presentation/settings/business_settings_screen.dart';
|
||||
|
||||
class ProfileScreen extends ConsumerStatefulWidget {
|
||||
const ProfileScreen({super.key});
|
||||
@@ -114,7 +116,37 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final isBusinessMode = ref.watch(businessModeProvider);
|
||||
return Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
secondary: const Icon(LucideIcons.briefcase),
|
||||
title: const Text('Business Mode'),
|
||||
subtitle: const Text('Inventory and sales management'),
|
||||
value: isBusinessMode,
|
||||
onChanged: (val) {
|
||||
ref.read(businessModeProvider.notifier).toggleMode();
|
||||
},
|
||||
),
|
||||
if (isBusinessMode) ...[
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.settings),
|
||||
title: const Text('Business Settings'),
|
||||
subtitle: const Text('Tax, Modules, POS'),
|
||||
trailing: const Icon(LucideIcons.chevronRight),
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const BusinessSettingsScreen()));
|
||||
},
|
||||
),
|
||||
]
|
||||
],
|
||||
);
|
||||
}
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.helpCircle),
|
||||
title: const Text('Help & Support'),
|
||||
|
||||
61
kifi-app/lib/features/business/domain/business_profile.dart
Normal file
61
kifi-app/lib/features/business/domain/business_profile.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
class BusinessProfile {
|
||||
final int? id;
|
||||
final int? userId;
|
||||
final String businessName;
|
||||
final String? industry;
|
||||
final String? taxNumber;
|
||||
final String? currency;
|
||||
final bool? taxIncludedInPrice;
|
||||
|
||||
BusinessProfile({
|
||||
this.id,
|
||||
this.userId,
|
||||
required this.businessName,
|
||||
this.industry,
|
||||
this.taxNumber,
|
||||
this.currency,
|
||||
this.taxIncludedInPrice,
|
||||
});
|
||||
|
||||
factory BusinessProfile.fromJson(Map<String, dynamic> json) {
|
||||
return BusinessProfile(
|
||||
id: json['id'],
|
||||
userId: json['userId'],
|
||||
businessName: json['businessName'],
|
||||
industry: json['industry'],
|
||||
taxNumber: json['taxNumber'],
|
||||
currency: json['currency'],
|
||||
taxIncludedInPrice: json['taxIncludedInPrice'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'userId': userId,
|
||||
'businessName': businessName,
|
||||
'industry': industry,
|
||||
'taxNumber': taxNumber,
|
||||
'currency': currency,
|
||||
'taxIncludedInPrice': taxIncludedInPrice,
|
||||
};
|
||||
}
|
||||
|
||||
BusinessProfile copyWith({
|
||||
String? businessName,
|
||||
String? industry,
|
||||
String? taxNumber,
|
||||
String? currency,
|
||||
bool? taxIncludedInPrice,
|
||||
}) {
|
||||
return BusinessProfile(
|
||||
id: id,
|
||||
userId: userId,
|
||||
businessName: businessName ?? this.businessName,
|
||||
industry: industry ?? this.industry,
|
||||
taxNumber: taxNumber ?? this.taxNumber,
|
||||
currency: currency ?? this.currency,
|
||||
taxIncludedInPrice: taxIncludedInPrice ?? this.taxIncludedInPrice,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../../core/theme/nature_colors.dart';
|
||||
import '../../../inventory/presentation/product_list_screen.dart';
|
||||
|
||||
class BusinessHubScreen extends ConsumerWidget {
|
||||
const BusinessHubScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.briefcase, color: Theme.of(context).colorScheme.primary, size: 32),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Business Overview', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const Text('Manage your inventory and stock', style: TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
GridView.count(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
childAspectRatio: 1.2,
|
||||
children: [
|
||||
_buildActionCard(
|
||||
context,
|
||||
'Products Catalog',
|
||||
'View all products',
|
||||
LucideIcons.packageSearch,
|
||||
Colors.blue,
|
||||
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductListScreen())),
|
||||
),
|
||||
_buildActionCard(
|
||||
context,
|
||||
'Adjust Stock',
|
||||
'Add or reduce inventory',
|
||||
LucideIcons.arrowRightLeft,
|
||||
Colors.orange,
|
||||
() {
|
||||
// TODO: Navigate to Stock Adjustment
|
||||
},
|
||||
),
|
||||
_buildActionCard(
|
||||
context,
|
||||
'Sales & Invoices',
|
||||
'Coming in Phase 2',
|
||||
LucideIcons.receipt,
|
||||
Colors.green,
|
||||
() {},
|
||||
),
|
||||
_buildActionCard(
|
||||
context,
|
||||
'Customers',
|
||||
'Coming in Phase 2',
|
||||
LucideIcons.users,
|
||||
NatureColors.getColor('RECEIVABLES'),
|
||||
() {},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text('Low Stock Alerts', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
// Placeholder for low stock items
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text('All products are adequately stocked.', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionCard(BuildContext context, String title, String subtitle, IconData icon, Color color, VoidCallback onTap) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: color.withOpacity(0.2)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 28),
|
||||
const SizedBox(height: 12),
|
||||
Text(title, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitle, style: TextStyle(color: color.withOpacity(0.7), fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../providers/business_provider.dart';
|
||||
|
||||
class BusinessSettingsScreen extends ConsumerStatefulWidget {
|
||||
const BusinessSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<BusinessSettingsScreen> createState() => _BusinessSettingsScreenState();
|
||||
}
|
||||
|
||||
class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen> {
|
||||
bool _taxIncludedInPrice = false;
|
||||
bool _salesEnabled = true;
|
||||
bool _inventoryEnabled = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final profile = ref.read(businessProfileProvider).value;
|
||||
if (profile != null) {
|
||||
setState(() {
|
||||
_taxIncludedInPrice = profile.taxIncludedInPrice ?? false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Business Settings'),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
children: [
|
||||
const Text('Module Configuration', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
title: const Text('Inventory Management'),
|
||||
subtitle: const Text('Track stock levels, multiple locations, and products'),
|
||||
value: _inventoryEnabled,
|
||||
onChanged: (val) => setState(() => _inventoryEnabled = val),
|
||||
secondary: const Icon(LucideIcons.package),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Sales & POS'),
|
||||
subtitle: const Text('Enable point of sale, invoicing, and receivables'),
|
||||
value: _salesEnabled,
|
||||
onChanged: (val) => setState(() => _salesEnabled = val),
|
||||
secondary: const Icon(LucideIcons.shoppingCart),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const Text('Pricing & Taxation', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
title: const Text('Selling Price includes Tax'),
|
||||
subtitle: const Text('If enabled, GST is assumed to be inclusive in the entered selling price.'),
|
||||
value: _taxIncludedInPrice,
|
||||
onChanged: (val) {
|
||||
setState(() => _taxIncludedInPrice = val);
|
||||
_saveSettings();
|
||||
},
|
||||
secondary: const Icon(LucideIcons.receipt),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _saveSettings() {
|
||||
final profileState = ref.read(businessProfileProvider).value;
|
||||
if (profileState != null) {
|
||||
final updated = profileState.copyWith(taxIncludedInPrice: _taxIncludedInPrice);
|
||||
ref.read(businessProfileProvider.notifier).updateProfile(updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class BusinessModeNotifier extends Notifier<bool> {
|
||||
@override
|
||||
bool build() {
|
||||
_loadState();
|
||||
return false; // Default until loaded
|
||||
}
|
||||
|
||||
Future<void> _loadState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
state = prefs.getBool('is_business_mode') ?? false;
|
||||
}
|
||||
|
||||
Future<void> toggleMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
state = !state;
|
||||
await prefs.setBool('is_business_mode', state);
|
||||
}
|
||||
}
|
||||
|
||||
final businessModeProvider = NotifierProvider<BusinessModeNotifier, bool>(() {
|
||||
return BusinessModeNotifier();
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import '../domain/business_profile.dart';
|
||||
|
||||
class BusinessProfileNotifier extends AsyncNotifier<BusinessProfile?> {
|
||||
@override
|
||||
FutureOr<BusinessProfile?> build() async {
|
||||
return _fetchProfile();
|
||||
}
|
||||
|
||||
Future<BusinessProfile?> _fetchProfile() async {
|
||||
final response = await DioClient().dio.get('/business/profile');
|
||||
if (response.statusCode == 200) {
|
||||
return BusinessProfile.fromJson(response.data);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> updateProfile(BusinessProfile profile) async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final response = await DioClient().dio.post(
|
||||
'/business/profile',
|
||||
data: profile.toJson(),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
state = AsyncValue.data(BusinessProfile.fromJson(response.data));
|
||||
}
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final businessProfileProvider = AsyncNotifierProvider<BusinessProfileNotifier, BusinessProfile?>(() {
|
||||
return BusinessProfileNotifier();
|
||||
});
|
||||
@@ -19,6 +19,8 @@ import '../../../core/theme/nature_colors.dart';
|
||||
import 'wallet_ledger_screen.dart';
|
||||
import 'maturity_dialog.dart';
|
||||
import 'accounts_screen.dart';
|
||||
import '../../business/providers/business_mode_provider.dart';
|
||||
import '../../business/presentation/hub/business_hub_screen.dart';
|
||||
|
||||
class DashboardScreen extends ConsumerStatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
@@ -137,6 +139,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
final categoriesState = ref.watch(categoryProvider);
|
||||
final insight = ref.watch(insightProvider);
|
||||
final walletsState = ref.watch(walletProvider);
|
||||
final isBusinessMode = ref.watch(businessModeProvider);
|
||||
final allTransactions = transState.value ?? [];
|
||||
|
||||
final range = _getDateRange(allTransactions);
|
||||
@@ -145,7 +148,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
|
||||
String title;
|
||||
if (_currentIndex == 0) title = 'Dashboard';
|
||||
else if (_currentIndex == 1) title = 'Statistics';
|
||||
else if (_currentIndex == 1) title = isBusinessMode ? 'Business Hub' : 'Statistics';
|
||||
else if (_currentIndex == 2) title = 'My Accounts';
|
||||
else title = 'Budgets';
|
||||
|
||||
@@ -512,8 +515,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
// ---------------- STATS TAB ----------------
|
||||
StatisticsTab(
|
||||
// ---------------- STATS/BUSINESS TAB ----------------
|
||||
isBusinessMode ? const BusinessHubScreen() : StatisticsTab(
|
||||
transactions: transactions,
|
||||
wallets: safeWallets,
|
||||
categories: categoriesState.value ?? [],
|
||||
@@ -553,12 +556,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
unselectedItemColor: Colors.grey,
|
||||
showSelectedLabels: true,
|
||||
showUnselectedLabels: true,
|
||||
items: const [
|
||||
BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'),
|
||||
BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'),
|
||||
BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'),
|
||||
BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'),
|
||||
BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'),
|
||||
items: [
|
||||
const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(isBusinessMode ? LucideIcons.briefcase : LucideIcons.pieChart),
|
||||
label: isBusinessMode ? 'Business' : 'Stats'),
|
||||
const BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'),
|
||||
const BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'),
|
||||
const BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
class InventoryMovementItem {
|
||||
final int? id;
|
||||
final int? movementId;
|
||||
final int productId;
|
||||
final double quantity;
|
||||
final double? unitPrice;
|
||||
|
||||
InventoryMovementItem({
|
||||
this.id,
|
||||
this.movementId,
|
||||
required this.productId,
|
||||
required this.quantity,
|
||||
this.unitPrice,
|
||||
});
|
||||
|
||||
factory InventoryMovementItem.fromJson(Map<String, dynamic> json) {
|
||||
return InventoryMovementItem(
|
||||
id: json['id'],
|
||||
movementId: json['movementId'],
|
||||
productId: json['productId'],
|
||||
quantity: (json['quantity'] as num).toDouble(),
|
||||
unitPrice: (json['unitPrice'] as num?)?.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'movementId': movementId,
|
||||
'productId': productId,
|
||||
'quantity': quantity,
|
||||
'unitPrice': unitPrice,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class InventoryMovement {
|
||||
final int? id;
|
||||
final int? userId;
|
||||
final int locationId;
|
||||
final String type;
|
||||
final int? referenceTransactionId;
|
||||
final String? notes;
|
||||
|
||||
InventoryMovement({
|
||||
this.id,
|
||||
this.userId,
|
||||
required this.locationId,
|
||||
required this.type,
|
||||
this.referenceTransactionId,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
factory InventoryMovement.fromJson(Map<String, dynamic> json) {
|
||||
return InventoryMovement(
|
||||
id: json['id'],
|
||||
userId: json['userId'],
|
||||
locationId: json['locationId'],
|
||||
type: json['type'],
|
||||
referenceTransactionId: json['referenceTransactionId'],
|
||||
notes: json['notes'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'userId': userId,
|
||||
'locationId': locationId,
|
||||
'type': type,
|
||||
'referenceTransactionId': referenceTransactionId,
|
||||
'notes': notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
120
kifi-app/lib/features/inventory/domain/product.dart
Normal file
120
kifi-app/lib/features/inventory/domain/product.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
class Product {
|
||||
final int? id;
|
||||
final int? userId;
|
||||
final int? categoryId;
|
||||
final int? uomId;
|
||||
final String name;
|
||||
final String? sku;
|
||||
final String? barcode;
|
||||
final String? description;
|
||||
final double? purchasePrice;
|
||||
final double? sellingPrice;
|
||||
final double? minStock;
|
||||
final double? reorderLevel;
|
||||
final double? gstRate;
|
||||
final String? dimensions;
|
||||
final double? weight;
|
||||
final String? color;
|
||||
final String? size;
|
||||
final String priceCalcRule;
|
||||
final bool autoCalculatePrice;
|
||||
final double? purityFactor;
|
||||
final double? makingCharges;
|
||||
final String? makingChargesType;
|
||||
final double? wastagePercentage;
|
||||
final bool trackInventory;
|
||||
final bool isActive;
|
||||
final List<int> imageIds;
|
||||
|
||||
Product({
|
||||
this.id,
|
||||
this.userId,
|
||||
this.categoryId,
|
||||
this.uomId,
|
||||
required this.name,
|
||||
this.sku,
|
||||
this.barcode,
|
||||
this.description,
|
||||
this.purchasePrice,
|
||||
this.sellingPrice,
|
||||
this.minStock,
|
||||
this.reorderLevel,
|
||||
this.gstRate,
|
||||
this.dimensions,
|
||||
this.weight,
|
||||
this.color,
|
||||
this.size,
|
||||
this.priceCalcRule = 'MANUAL',
|
||||
this.autoCalculatePrice = false,
|
||||
this.purityFactor = 1.0,
|
||||
this.makingCharges = 0.0,
|
||||
this.makingChargesType = 'FLAT',
|
||||
this.wastagePercentage = 0.0,
|
||||
this.trackInventory = true,
|
||||
this.isActive = true,
|
||||
this.imageIds = const [],
|
||||
});
|
||||
|
||||
factory Product.fromJson(Map<String, dynamic> json) {
|
||||
return Product(
|
||||
id: json['id'],
|
||||
userId: json['userId'],
|
||||
categoryId: json['categoryId'],
|
||||
uomId: json['uomId'],
|
||||
name: json['name'],
|
||||
sku: json['sku'],
|
||||
barcode: json['barcode'],
|
||||
description: json['description'],
|
||||
purchasePrice: (json['purchasePrice'] as num?)?.toDouble(),
|
||||
sellingPrice: (json['sellingPrice'] as num?)?.toDouble(),
|
||||
minStock: (json['minStock'] as num?)?.toDouble(),
|
||||
reorderLevel: (json['reorderLevel'] as num?)?.toDouble(),
|
||||
gstRate: (json['gstRate'] as num?)?.toDouble(),
|
||||
dimensions: json['dimensions'],
|
||||
weight: (json['weight'] as num?)?.toDouble(),
|
||||
color: json['color'],
|
||||
size: json['size'],
|
||||
priceCalcRule: json['priceCalcRule'] ?? 'MANUAL',
|
||||
autoCalculatePrice: json['autoCalculatePrice'] ?? false,
|
||||
purityFactor: (json['purityFactor'] as num?)?.toDouble() ?? 1.0,
|
||||
makingCharges: (json['makingCharges'] as num?)?.toDouble() ?? 0.0,
|
||||
makingChargesType: json['makingChargesType'] ?? 'FLAT',
|
||||
wastagePercentage: (json['wastagePercentage'] as num?)?.toDouble() ?? 0.0,
|
||||
trackInventory: json['trackInventory'] ?? true,
|
||||
isActive: json['isActive'] ?? true,
|
||||
imageIds: json['images'] != null
|
||||
? (json['images'] as List).map((i) => i['id'] as int).toList()
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'userId': userId,
|
||||
'categoryId': categoryId,
|
||||
'uomId': uomId,
|
||||
'name': name,
|
||||
'sku': sku,
|
||||
'barcode': barcode,
|
||||
'description': description,
|
||||
'purchasePrice': purchasePrice,
|
||||
'sellingPrice': sellingPrice,
|
||||
'minStock': minStock,
|
||||
'reorderLevel': reorderLevel,
|
||||
'gstRate': gstRate,
|
||||
'dimensions': dimensions,
|
||||
'weight': weight,
|
||||
'color': color,
|
||||
'size': size,
|
||||
'priceCalcRule': priceCalcRule,
|
||||
'autoCalculatePrice': autoCalculatePrice,
|
||||
'purityFactor': purityFactor,
|
||||
'makingCharges': makingCharges,
|
||||
'makingChargesType': makingChargesType,
|
||||
'wastagePercentage': wastagePercentage,
|
||||
'trackInventory': trackInventory,
|
||||
'isActive': isActive,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import '../domain/product.dart';
|
||||
import '../providers/products_provider.dart';
|
||||
import '../providers/product_categories_provider.dart';
|
||||
import '../../business/providers/business_provider.dart';
|
||||
|
||||
class AddProductScreen extends ConsumerStatefulWidget {
|
||||
final Product? product;
|
||||
const AddProductScreen({super.key, this.product});
|
||||
|
||||
@override
|
||||
ConsumerState<AddProductScreen> createState() => _AddProductScreenState();
|
||||
}
|
||||
|
||||
class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
final PageController _pageController = PageController();
|
||||
int _currentPage = 0;
|
||||
final int _totalPages = 4;
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Basic
|
||||
String _name = '';
|
||||
String _sku = '';
|
||||
ProductCategory? _selectedCategory;
|
||||
|
||||
// Properties
|
||||
String _color = '';
|
||||
String _size = '';
|
||||
String _dimensions = '';
|
||||
double _weight = 0;
|
||||
|
||||
// Pricing & Inventory
|
||||
double _purchasePrice = 0;
|
||||
double _sellingPrice = 0;
|
||||
double _gstRate = 0;
|
||||
bool _trackInventory = true;
|
||||
bool _autoCalculatePrice = false;
|
||||
|
||||
// Advanced Commodity Fields
|
||||
double _purityFactor = 1.0;
|
||||
double _makingCharges = 0;
|
||||
String _makingChargesType = 'FLAT'; // FLAT, PER_UNIT, PERCENTAGE
|
||||
double _wastagePercentage = 0;
|
||||
|
||||
// Media
|
||||
final List<XFile> _images = [];
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.product != null) {
|
||||
final p = widget.product!;
|
||||
_name = p.name;
|
||||
_sku = p.sku ?? '';
|
||||
_color = p.color ?? '';
|
||||
_size = p.size ?? '';
|
||||
_dimensions = p.dimensions ?? '';
|
||||
_weight = p.weight ?? 0;
|
||||
_purchasePrice = p.purchasePrice ?? 0;
|
||||
_sellingPrice = p.sellingPrice ?? 0;
|
||||
_gstRate = p.gstRate ?? 0;
|
||||
_trackInventory = p.trackInventory;
|
||||
_autoCalculatePrice = p.autoCalculatePrice;
|
||||
_purityFactor = p.purityFactor ?? 1.0;
|
||||
_makingCharges = p.makingCharges ?? 0.0;
|
||||
_makingChargesType = p.makingChargesType ?? 'FLAT';
|
||||
_wastagePercentage = p.wastagePercentage ?? 0.0;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final cats = ref.read(productCategoriesProvider).value ?? [];
|
||||
if (cats.isNotEmpty) {
|
||||
setState(() {
|
||||
_selectedCategory = cats.firstWhere((c) => c.id == p.categoryId, orElse: () => cats.first);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _nextPage() {
|
||||
FocusScope.of(context).unfocus();
|
||||
if (_currentPage < _totalPages - 1) {
|
||||
if (_currentPage == 0 && _selectedCategory == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a category.')));
|
||||
return;
|
||||
}
|
||||
_pageController.animateToPage(_currentPage + 1, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
|
||||
} else {
|
||||
_save();
|
||||
}
|
||||
}
|
||||
|
||||
void _prevPage() {
|
||||
FocusScope.of(context).unfocus();
|
||||
if (_currentPage > 0) {
|
||||
_pageController.animateToPage(_currentPage - 1, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickImages() async {
|
||||
if (_images.length >= 4) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 4 images allowed')));
|
||||
return;
|
||||
}
|
||||
final ImagePicker picker = ImagePicker();
|
||||
final List<XFile> picked = await picker.pickMultiImage();
|
||||
if (picked.isNotEmpty) {
|
||||
setState(() {
|
||||
_images.addAll(picked.take(4 - _images.length));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _removeImage(int index) {
|
||||
setState(() {
|
||||
_images.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
void _showAddCategoryDialog() {
|
||||
String newCatName = '';
|
||||
bool newCatCommodity = false;
|
||||
double newCatRate = 0;
|
||||
String newCalcMethod = 'UNIT'; // WEIGHT, UNIT, VOLUME
|
||||
String newBaseUnit = 'pcs';
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
title: const Text('New Category', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildPremiumTextField(
|
||||
label: 'Category Name',
|
||||
onChanged: (val) => newCatName = val,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Is this a Commodity?'),
|
||||
subtitle: const Text('Enable for daily rate pricing (e.g. Gold)'),
|
||||
value: newCatCommodity,
|
||||
onChanged: (val) => setDialogState(() => newCatCommodity = val),
|
||||
),
|
||||
if (newCatCommodity) ...[
|
||||
_buildPremiumDropdown(
|
||||
label: 'Calculation Method',
|
||||
value: newCalcMethod,
|
||||
items: ['UNIT', 'WEIGHT', 'VOLUME'],
|
||||
onChanged: (val) => setDialogState(() {
|
||||
newCalcMethod = val!;
|
||||
if (val == 'WEIGHT') newBaseUnit = 'gm';
|
||||
else if (val == 'VOLUME') newBaseUnit = 'liter';
|
||||
else newBaseUnit = 'pcs';
|
||||
})
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildPremiumTextField(
|
||||
label: 'Base Unit (e.g. gm, kg, pcs)',
|
||||
initialValue: newBaseUnit,
|
||||
onChanged: (val) => newBaseUnit = val,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildPremiumTextField(
|
||||
label: 'Daily Rate',
|
||||
prefixText: '₹',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (val) => newCatRate = double.tryParse(val) ?? 0,
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
onPressed: () async {
|
||||
if (newCatName.trim().isEmpty) return;
|
||||
final newCategory = ProductCategory(
|
||||
name: newCatName.trim(),
|
||||
isCommodity: newCatCommodity,
|
||||
calculationMethod: newCalcMethod,
|
||||
baseUnit: newBaseUnit,
|
||||
dailyRate: newCatCommodity ? newCatRate : null,
|
||||
);
|
||||
await ref.read(productCategoriesProvider.notifier).createCategory(newCategory);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
_formKey.currentState!.save();
|
||||
|
||||
if (_selectedCategory == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a category')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
final product = Product(
|
||||
name: _name,
|
||||
sku: _sku.isNotEmpty ? _sku : null,
|
||||
purchasePrice: _purchasePrice,
|
||||
sellingPrice: _autoCalculatePrice ? _calculateLivePrice() : _sellingPrice,
|
||||
gstRate: _gstRate,
|
||||
weight: _weight,
|
||||
color: _color,
|
||||
size: _size,
|
||||
dimensions: _dimensions,
|
||||
priceCalcRule: _autoCalculatePrice ? 'COMMODITY' : 'MANUAL',
|
||||
autoCalculatePrice: _autoCalculatePrice,
|
||||
purityFactor: _purityFactor,
|
||||
makingCharges: _makingCharges,
|
||||
makingChargesType: _makingChargesType,
|
||||
wastagePercentage: _wastagePercentage,
|
||||
trackInventory: _trackInventory,
|
||||
);
|
||||
|
||||
if (widget.product != null) {
|
||||
await ref.read(productsProvider.notifier).updateProduct(widget.product!.id!, product, newImages: _images);
|
||||
} else {
|
||||
await ref.read(productsProvider.notifier).createProduct(product, images: _images);
|
||||
}
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
double _calculateLivePrice() {
|
||||
if (_selectedCategory == null || !_selectedCategory!.isCommodity) return _sellingPrice;
|
||||
double rate = _selectedCategory!.dailyRate ?? 0;
|
||||
double baseVal = (_selectedCategory!.calculationMethod == 'WEIGHT') ? _weight : 1.0;
|
||||
|
||||
// Base Material Cost = (Weight + Wastage) * Rate * Purity
|
||||
double materialWeight = baseVal + (baseVal * (_wastagePercentage / 100));
|
||||
double materialCost = materialWeight * rate * _purityFactor;
|
||||
|
||||
// Making Charges
|
||||
double making = 0;
|
||||
if (_makingChargesType == 'FLAT') making = _makingCharges;
|
||||
else if (_makingChargesType == 'PER_UNIT') making = _makingCharges * baseVal;
|
||||
else if (_makingChargesType == 'PERCENTAGE') making = materialCost * (_makingCharges / 100);
|
||||
|
||||
return materialCost + making;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
appBar: AppBar(
|
||||
title: Text(widget.product != null ? 'Edit Product' : 'New Product', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
),
|
||||
body: GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildProgressPills(),
|
||||
Expanded(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: PageView(
|
||||
controller: _pageController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
onPageChanged: (idx) {
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _currentPage = idx);
|
||||
},
|
||||
children: [
|
||||
_buildBasicStep(),
|
||||
_buildPropertiesStep(),
|
||||
_buildPricingStep(),
|
||||
_buildMediaStep(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildBottomBar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressPills() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
child: Row(
|
||||
children: List.generate(_totalPages, (index) {
|
||||
bool isActive = index <= _currentPage;
|
||||
return Expanded(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
margin: EdgeInsets.only(right: index == _totalPages - 1 ? 0 : 8),
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? Theme.of(context).colorScheme.primary : Colors.grey.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomBar() {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (_currentPage > 0)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: OutlinedButton(
|
||||
onPressed: _isSaving ? null : _prevPage,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
child: const Icon(LucideIcons.chevronLeft),
|
||||
),
|
||||
),
|
||||
if (_currentPage > 0) const SizedBox(width: 16),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isSaving ? null : _nextPage,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
elevation: 4,
|
||||
shadowColor: Theme.of(context).colorScheme.primary.withOpacity(0.4),
|
||||
),
|
||||
child: _isSaving
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: Text(
|
||||
_currentPage == _totalPages - 1
|
||||
? (widget.product != null ? 'Update Product' : 'Publish Product')
|
||||
: 'Continue',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ==== STEPS ====
|
||||
|
||||
Widget _buildBasicStep() {
|
||||
final categoriesState = ref.watch(productCategoriesProvider);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Basic Information', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Let\'s start with the core details of your product.', style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
_buildPremiumTextField(
|
||||
label: 'Product Name*',
|
||||
initialValue: _name,
|
||||
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
|
||||
onChanged: (val) => _name = val,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildPremiumTextField(
|
||||
label: 'SKU / Barcode',
|
||||
initialValue: _sku,
|
||||
onChanged: (val) => _sku = val,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
const Text('Category', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
categoriesState.when(
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (err, stack) => Text('Error loading categories: $err'),
|
||||
data: (categories) {
|
||||
final leafCategories = categories.where((c) => !categories.any((child) => child.parentCategoryId == c.id)).toList();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (leafCategories.isEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.orange.withOpacity(0.1), borderRadius: BorderRadius.circular(16)),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(LucideIcons.alertCircle, color: Colors.orange),
|
||||
SizedBox(width: 12),
|
||||
Expanded(child: Text('No categories found. Create one to organize your inventory.', style: TextStyle(color: Colors.orange))),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildPremiumDropdown<ProductCategory>(
|
||||
label: 'Select Category*',
|
||||
value: _selectedCategory,
|
||||
items: leafCategories,
|
||||
itemLabel: (c) {
|
||||
String displayName = c.name;
|
||||
if (c.parentCategoryId != null) {
|
||||
final parent = categories.firstWhere((p) => p.id == c.parentCategoryId, orElse: () => c);
|
||||
displayName = '${parent.name} > ${c.name}';
|
||||
}
|
||||
return displayName;
|
||||
},
|
||||
onChanged: (val) => setState(() => _selectedCategory = val),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
GestureDetector(
|
||||
onTap: _showAddCategoryDialog,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.plusCircle, size: 18, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text('Create New Category', style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPropertiesStep() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Properties & Attributes', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Define the physical characteristics.', style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
_buildPremiumTextField(
|
||||
label: 'Weight',
|
||||
initialValue: _weight == 0 ? '' : _weight.toString(),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (val) => setState(() => _weight = double.tryParse(val) ?? 0),
|
||||
suffixText: _selectedCategory?.baseUnit ?? 'unit',
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildPremiumTextField(
|
||||
label: 'Color',
|
||||
initialValue: _color,
|
||||
onChanged: (val) => _color = val,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildPremiumTextField(
|
||||
label: 'Size',
|
||||
initialValue: _size,
|
||||
onChanged: (val) => _size = val,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildPremiumTextField(
|
||||
label: 'Dimensions',
|
||||
initialValue: _dimensions,
|
||||
onChanged: (val) => _dimensions = val,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPricingStep() {
|
||||
final taxInclusive = ref.watch(businessProfileProvider).value?.taxIncludedInPrice ?? false;
|
||||
final isCommodity = _selectedCategory?.isCommodity ?? false;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Pricing & Inventory', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Set up costs, pricing rules, and tracking.', style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
if (isCommodity) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(colors: [Colors.blue.shade800, Colors.blue.shade500]),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [BoxShadow(color: Colors.blue.withOpacity(0.3), blurRadius: 10, offset: const Offset(0, 4))],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.activity, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Commodity Pricing', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const Spacer(),
|
||||
Switch(
|
||||
value: _autoCalculatePrice,
|
||||
activeColor: Colors.white,
|
||||
onChanged: (val) => setState(() => _autoCalculatePrice = val),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('Daily Rate: ₹${_selectedCategory!.dailyRate ?? 0} / ${_selectedCategory!.baseUnit}', style: const TextStyle(color: Colors.white70)),
|
||||
if (_autoCalculatePrice) ...[
|
||||
const Divider(color: Colors.white24, height: 32),
|
||||
_buildPremiumTextField(
|
||||
label: 'Purity Factor (e.g. 0.916 for 22K)',
|
||||
initialValue: _purityFactor.toString(),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (val) => setState(() => _purityFactor = double.tryParse(val) ?? 1.0),
|
||||
darkTheme: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildPremiumTextField(
|
||||
label: 'Wastage Percentage (%)',
|
||||
initialValue: _wastagePercentage.toString(),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (val) => setState(() => _wastagePercentage = double.tryParse(val) ?? 0.0),
|
||||
darkTheme: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _buildPremiumTextField(
|
||||
label: 'Making Charges',
|
||||
initialValue: _makingCharges.toString(),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (val) => setState(() => _makingCharges = double.tryParse(val) ?? 0.0),
|
||||
darkTheme: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: _buildPremiumDropdown<String>(
|
||||
label: 'Type',
|
||||
value: _makingChargesType,
|
||||
items: const ['FLAT', 'PER_UNIT', 'PERCENTAGE'],
|
||||
itemLabel: (t) => t == 'PER_UNIT' ? 'Per ${_selectedCategory!.baseUnit}' : t == 'PERCENTAGE' ? '%' : 'Flat',
|
||||
onChanged: (val) => setState(() => _makingChargesType = val!),
|
||||
darkTheme: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(12)),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Live Selling Price:', style: TextStyle(color: Colors.white70)),
|
||||
Text('₹${_calculateLivePrice().toStringAsFixed(2)}', style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
)
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
|
||||
if (!_autoCalculatePrice) ...[
|
||||
_buildPremiumTextField(
|
||||
label: 'Purchase Price',
|
||||
initialValue: _purchasePrice == 0 ? '' : _purchasePrice.toString(),
|
||||
prefixText: '₹ ',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (val) => _purchasePrice = double.tryParse(val) ?? 0,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildPremiumTextField(
|
||||
label: taxInclusive ? 'Selling Price (Inc. Tax)*' : 'Selling Price (Exc. Tax)*',
|
||||
initialValue: _sellingPrice == 0 ? '' : _sellingPrice.toString(),
|
||||
prefixText: '₹ ',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
|
||||
onChanged: (val) => _sellingPrice = double.tryParse(val) ?? 0,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
_buildPremiumTextField(
|
||||
label: 'GST Rate (%)',
|
||||
initialValue: _gstRate == 0 ? '' : _gstRate.toString(),
|
||||
suffixText: '%',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (val) => _gstRate = double.tryParse(val) ?? 0,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
title: const Text('Track Inventory', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: const Text('Monitor stock levels automatically'),
|
||||
value: _trackInventory,
|
||||
onChanged: (val) => setState(() => _trackInventory = val),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMediaStep() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Product Images', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Upload up to 4 high-quality images.', style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
if (_images.isNotEmpty)
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemCount: _images.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
color: Colors.grey[200],
|
||||
child: Image.file(
|
||||
File(_images[index].path),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: GestureDetector(
|
||||
onTap: () => _removeImage(index),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: const Icon(LucideIcons.x, color: Colors.white, size: 16),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_images.length < 4)
|
||||
GestureDetector(
|
||||
onTap: _pickImages,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.05),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.3), style: BorderStyle.solid),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(LucideIcons.uploadCloud, size: 48, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Tap to Upload', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
Text('${4 - _images.length} slots remaining', style: const TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ==== WIDGET HELPERS ====
|
||||
|
||||
Widget _buildPremiumTextField({
|
||||
required String label,
|
||||
String? initialValue,
|
||||
String? prefixText,
|
||||
String? suffixText,
|
||||
TextInputType? keyboardType,
|
||||
void Function(String)? onChanged,
|
||||
void Function(String?)? onSaved,
|
||||
String? Function(String?)? validator,
|
||||
bool darkTheme = false,
|
||||
}) {
|
||||
return TextFormField(
|
||||
initialValue: initialValue,
|
||||
style: TextStyle(color: darkTheme ? Colors.white : null),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey[600]),
|
||||
prefixText: prefixText,
|
||||
prefixStyle: TextStyle(color: darkTheme ? Colors.white : Colors.black, fontWeight: FontWeight.bold),
|
||||
suffixText: suffixText,
|
||||
suffixStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey),
|
||||
filled: true,
|
||||
fillColor: darkTheme ? Colors.black26 : Colors.grey[100],
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: darkTheme ? Colors.white : Theme.of(context).colorScheme.primary, width: 2)
|
||||
),
|
||||
),
|
||||
keyboardType: keyboardType,
|
||||
onChanged: onChanged,
|
||||
onSaved: onSaved,
|
||||
validator: validator,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPremiumDropdown<T>({
|
||||
required String label,
|
||||
required T? value,
|
||||
required List<T> items,
|
||||
String Function(T)? itemLabel,
|
||||
required void Function(T?) onChanged,
|
||||
bool darkTheme = false,
|
||||
}) {
|
||||
return DropdownButtonFormField<T>(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey[600]),
|
||||
filled: true,
|
||||
fillColor: darkTheme ? Colors.black26 : Colors.grey[100],
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: darkTheme ? Colors.white : Theme.of(context).colorScheme.primary, width: 2)
|
||||
),
|
||||
),
|
||||
dropdownColor: darkTheme ? Colors.blue.shade900 : null,
|
||||
style: TextStyle(color: darkTheme ? Colors.white : Colors.black87, fontSize: 16),
|
||||
value: value,
|
||||
items: items.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(itemLabel != null ? itemLabel(e) : e.toString()),
|
||||
)).toList(),
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../providers/products_provider.dart';
|
||||
import '../providers/product_categories_provider.dart';
|
||||
import 'add_product_screen.dart';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class ProductListScreen extends ConsumerStatefulWidget {
|
||||
const ProductListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProductListScreen> createState() => _ProductListScreenState();
|
||||
}
|
||||
|
||||
class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
String _searchQuery = '';
|
||||
String? _token;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadToken();
|
||||
}
|
||||
|
||||
Future<void> _loadToken() async {
|
||||
_token = await const FlutterSecureStorage().read(key: 'jwt_token');
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
|
||||
ref.read(productsProvider.notifier).fetchNextPage();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final productsState = ref.watch(productsProvider);
|
||||
final categoriesState = ref.watch(productCategoriesProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Products Catalog'),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search products by name or SKU...',
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_searchQuery = val.toLowerCase();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: productsState.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
data: (products) {
|
||||
final filtered = products.where((p) => p.name.toLowerCase().contains(_searchQuery) || (p.sku != null && p.sku!.toLowerCase().contains(_searchQuery))).toList();
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
|
||||
child: ListView(
|
||||
children: const [
|
||||
SizedBox(height: 100),
|
||||
Center(child: Text('No products found.', style: TextStyle(color: Colors.grey)))
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: filtered.length + 1, // +1 for loading indicator
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == filtered.length) {
|
||||
// We reached the end of the filtered list, show a loader if we are loading more
|
||||
// The notifier state doesn't expose _isLoadingMore cleanly without another property,
|
||||
// but if we are at the end, we can just return a tiny spacer.
|
||||
return const SizedBox(height: 80);
|
||||
}
|
||||
|
||||
final p = filtered[index];
|
||||
final catName = categoriesState.value?.firstWhere((c) => c.id == p.categoryId, orElse: () => categoriesState.value!.first).name ?? 'No Category';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
elevation: 2,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => AddProductScreen(product: p)));
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildProductImage(p),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(color: Colors.blue.withOpacity(0.1), borderRadius: BorderRadius.circular(8)),
|
||||
child: Text(catName, style: const TextStyle(color: Colors.blue, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
if (p.sku != null && p.sku!.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(p.sku!, style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
]
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'₹${(p.sellingPrice ?? 0).toStringAsFixed(2)}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
p.trackInventory ? 'Stock: 0' : 'Untracked', // We'll link stock later
|
||||
style: TextStyle(
|
||||
color: p.trackInventory ? Colors.orange : Colors.grey,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddProductScreen()));
|
||||
},
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
child: const Icon(LucideIcons.plus, color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProductImage(product) {
|
||||
if (product.imageIds.isEmpty) {
|
||||
return Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(LucideIcons.package, color: Colors.grey),
|
||||
);
|
||||
}
|
||||
|
||||
final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${product.id}/images/${product.imageIds.first}/content';
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
color: Colors.grey[200],
|
||||
child: _token == null
|
||||
? const Icon(LucideIcons.image, color: Colors.grey)
|
||||
: Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
headers: {'Authorization': 'Bearer $_token'},
|
||||
errorBuilder: (context, error, stackTrace) => const Icon(LucideIcons.imageOff, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
|
||||
class ProductCategory {
|
||||
final int? id;
|
||||
final int? userId;
|
||||
final String name;
|
||||
final int? parentCategoryId;
|
||||
final bool isCommodity;
|
||||
final String calculationMethod;
|
||||
final String baseUnit;
|
||||
final double? dailyRate;
|
||||
|
||||
ProductCategory({
|
||||
this.id,
|
||||
this.userId,
|
||||
required this.name,
|
||||
this.parentCategoryId,
|
||||
this.isCommodity = false,
|
||||
this.calculationMethod = 'UNIT',
|
||||
this.baseUnit = 'pcs',
|
||||
this.dailyRate,
|
||||
});
|
||||
|
||||
factory ProductCategory.fromJson(Map<String, dynamic> json) {
|
||||
return ProductCategory(
|
||||
id: json['id'],
|
||||
userId: json['userId'],
|
||||
name: json['name'],
|
||||
parentCategoryId: json['parentCategoryId'],
|
||||
isCommodity: json['isCommodity'] ?? false,
|
||||
calculationMethod: json['calculationMethod'] ?? 'UNIT',
|
||||
baseUnit: json['baseUnit'] ?? 'pcs',
|
||||
dailyRate: (json['dailyRate'] as num?)?.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'userId': userId,
|
||||
'name': name,
|
||||
'parentCategoryId': parentCategoryId,
|
||||
'isCommodity': isCommodity,
|
||||
'calculationMethod': calculationMethod,
|
||||
'baseUnit': baseUnit,
|
||||
'dailyRate': dailyRate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ProductCategoriesNotifier extends AsyncNotifier<List<ProductCategory>> {
|
||||
@override
|
||||
FutureOr<List<ProductCategory>> build() async {
|
||||
return _fetchCategories();
|
||||
}
|
||||
|
||||
Future<List<ProductCategory>> _fetchCategories() async {
|
||||
final response = await DioClient().dio.get('/inventory/categories');
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((e) => ProductCategory.fromJson(e)).toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<void> createCategory(ProductCategory category) async {
|
||||
try {
|
||||
await DioClient().dio.post(
|
||||
'/inventory/categories',
|
||||
data: category.toJson(),
|
||||
);
|
||||
state = AsyncValue.data(await _fetchCategories());
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final productCategoriesProvider = AsyncNotifierProvider<ProductCategoriesNotifier, List<ProductCategory>>(() {
|
||||
return ProductCategoriesNotifier();
|
||||
});
|
||||
123
kifi-app/lib/features/inventory/providers/products_provider.dart
Normal file
123
kifi-app/lib/features/inventory/providers/products_provider.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dart:io';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import '../domain/product.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
class ProductsNotifier extends AsyncNotifier<List<Product>> {
|
||||
int _currentPage = 0;
|
||||
bool _hasMore = true;
|
||||
bool _isLoadingMore = false;
|
||||
final int _pageSize = 20;
|
||||
|
||||
@override
|
||||
FutureOr<List<Product>> build() async {
|
||||
_currentPage = 0;
|
||||
_hasMore = true;
|
||||
return _fetchProducts(page: _currentPage, size: _pageSize);
|
||||
}
|
||||
|
||||
Future<List<Product>> _fetchProducts({required int page, required int size}) async {
|
||||
final response = await DioClient().dio.get(
|
||||
'/inventory/products',
|
||||
queryParameters: {'page': page, 'size': size}
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = response.data;
|
||||
final products = data.map((e) => Product.fromJson(e)).toList();
|
||||
if (products.length < size) {
|
||||
_hasMore = false;
|
||||
}
|
||||
return products;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<void> fetchNextPage() async {
|
||||
if (!_hasMore || _isLoadingMore || state.isLoading) return;
|
||||
|
||||
_isLoadingMore = true;
|
||||
try {
|
||||
final currentList = state.value ?? [];
|
||||
final nextPage = _currentPage + 1;
|
||||
final newProducts = await _fetchProducts(page: nextPage, size: _pageSize);
|
||||
|
||||
_currentPage = nextPage;
|
||||
state = AsyncValue.data([...currentList, ...newProducts]);
|
||||
} catch (e, stack) {
|
||||
// Don't override state with error, just keep the current list, but maybe show a toast.
|
||||
} finally {
|
||||
_isLoadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
_currentPage = 0;
|
||||
_hasMore = true;
|
||||
try {
|
||||
state = AsyncValue.data(await _fetchProducts(page: _currentPage, size: _pageSize));
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createProduct(Product product, {List<XFile>? images}) async {
|
||||
try {
|
||||
final response = await DioClient().dio.post(
|
||||
'/inventory/products',
|
||||
data: product.toJson(),
|
||||
);
|
||||
|
||||
if (images != null && images.isNotEmpty && response.data != null) {
|
||||
final productId = response.data['id'];
|
||||
for (var image in images) {
|
||||
final formData = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(image.path, filename: image.name),
|
||||
});
|
||||
await DioClient().dio.post(
|
||||
'/inventory/products/$productId/images',
|
||||
data: formData,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh list
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateProduct(int id, Product product, {List<XFile>? newImages}) async {
|
||||
try {
|
||||
await DioClient().dio.put(
|
||||
'/inventory/products/$id',
|
||||
data: product.toJson(),
|
||||
);
|
||||
|
||||
if (newImages != null && newImages.isNotEmpty) {
|
||||
for (var image in newImages) {
|
||||
final formData = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(image.path, filename: image.name),
|
||||
});
|
||||
await DioClient().dio.post(
|
||||
'/inventory/products/$id/images',
|
||||
data: formData,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh list
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final productsProvider = AsyncNotifierProvider<ProductsNotifier, List<Product>>(() {
|
||||
return ProductsNotifier();
|
||||
});
|
||||
Reference in New Issue
Block a user