Web approach - basic look and feel done

This commit is contained in:
2026-08-29 21:52:11 +05:30
parent 364961c9de
commit 372c2bc14d
71 changed files with 2209 additions and 1044 deletions

View File

@@ -14,6 +14,12 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.SecurityWebFilterChain;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsConfigurationSource;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
@Configuration @Configuration
@EnableWebFluxSecurity @EnableWebFluxSecurity
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -27,9 +33,25 @@ public class SecurityConfig {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
} }
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setExposedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean @Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http return http
.cors(corsSpec -> corsSpec.configurationSource(corsConfigurationSource()))
.exceptionHandling(exceptionHandlingSpec -> exceptionHandlingSpec .exceptionHandling(exceptionHandlingSpec -> exceptionHandlingSpec
.authenticationEntryPoint((swe, e) -> .authenticationEntryPoint((swe, e) ->
Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED)) Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED))

View File

@@ -1,6 +1,7 @@
package com.kifi.api.controller.vendor; package com.kifi.api.controller.vendor;
import com.kifi.api.entity.vendor.PurchaseOrder; import com.kifi.api.entity.vendor.PurchaseOrder;
import com.kifi.api.entity.vendor.PurchaseOrderItem;
import com.kifi.api.entity.vendor.PurchasePayment; import com.kifi.api.entity.vendor.PurchasePayment;
import com.kifi.api.service.vendor.PurchaseOrderService; import com.kifi.api.service.vendor.PurchaseOrderService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -67,11 +68,15 @@ public class PurchaseOrderController {
} }
@PutMapping("/{id}/receive") @PutMapping("/{id}/receive")
public Mono<ResponseEntity<PurchaseOrder>> markAsReceived(@PathVariable Long id, Authentication authentication, @RequestBody PurchaseOrder po) { public Mono<ResponseEntity<PurchaseOrder>> markAsReceived(@PathVariable Long id, Authentication authentication, @RequestBody(required = false) PurchaseOrder po) {
Long userId = Long.valueOf(authentication.getDetails().toString()); Long userId = Long.valueOf(authentication.getDetails().toString());
return purchaseOrderService.markAsReceived(userId, id, po.getItems()) List<PurchaseOrderItem> items = (po != null) ? po.getItems() : null;
return purchaseOrderService.markAsReceived(userId, id, items)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.onErrorResume(e -> Mono.just(ResponseEntity.badRequest().build())); .onErrorResume(e -> {
e.printStackTrace();
return Mono.just(ResponseEntity.internalServerError().build());
});
} }
@PostMapping("/{id}/payments") @PostMapping("/{id}/payments")

View File

@@ -12,7 +12,10 @@ import com.kifi.api.entity.inventory.InventoryItem;
import com.kifi.api.service.accounting.LedgerService; import com.kifi.api.service.accounting.LedgerService;
import com.kifi.api.service.TransactionService; import com.kifi.api.service.TransactionService;
import com.kifi.api.repository.vendor.VendorRepository; import com.kifi.api.repository.vendor.VendorRepository;
import com.kifi.api.entity.Transaction; import com.kifi.api.entity.inventory.InventoryLocation;
import com.kifi.api.entity.inventory.InventoryBalance;
import com.kifi.api.repository.inventory.InventoryLocationRepository;
import com.kifi.api.repository.inventory.InventoryBalanceRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
@@ -35,7 +38,8 @@ public class PurchaseOrderService {
private final LedgerService ledgerService; private final LedgerService ledgerService;
private final TransactionService transactionService; private final TransactionService transactionService;
private final VendorRepository vendorRepository; private final VendorRepository vendorRepository;
private final com.kifi.api.repository.inventory.InventoryBalanceRepository inventoryBalanceRepository; private final InventoryBalanceRepository inventoryBalanceRepository;
private final InventoryLocationRepository inventoryLocationRepository;
public Flux<PurchaseOrder> getPurchaseOrders(Long userId) { public Flux<PurchaseOrder> getPurchaseOrders(Long userId) {
return purchaseOrderRepository.findByUserId(userId) return purchaseOrderRepository.findByUserId(userId)
@@ -134,6 +138,7 @@ public class PurchaseOrderService {
@Transactional @Transactional
public Mono<PurchaseOrder> markAsReceived(Long userId, Long id, List<PurchaseOrderItem> receivedItems) { public Mono<PurchaseOrder> markAsReceived(Long userId, Long id, List<PurchaseOrderItem> receivedItems) {
return purchaseOrderRepository.findByUserIdAndId(userId, id) return purchaseOrderRepository.findByUserIdAndId(userId, id)
.switchIfEmpty(Mono.error(new RuntimeException("PO not found")))
.flatMap(po -> { .flatMap(po -> {
if ("RECEIVED".equals(po.getStatus())) { if ("RECEIVED".equals(po.getStatus())) {
return Mono.error(new RuntimeException("PO is already marked as received")); return Mono.error(new RuntimeException("PO is already marked as received"));
@@ -142,19 +147,41 @@ public class PurchaseOrderService {
po.setStatus("RECEIVED"); po.setStatus("RECEIVED");
po.setUpdatedAt(LocalDateTime.now()); po.setUpdatedAt(LocalDateTime.now());
List<PurchaseOrderItem> itemsToSave = (receivedItems != null && !receivedItems.isEmpty()) ? receivedItems : po.getItems();
return purchaseOrderRepository.save(po) return purchaseOrderRepository.save(po)
.flatMap(savedPo -> purchaseOrderItemRepository.deleteByPoId(id) .flatMap(savedPo -> {
.thenMany(Flux.fromIterable(receivedItems) Mono<List<PurchaseOrderItem>> itemsProcess;
if (itemsToSave != null && !itemsToSave.isEmpty()) {
itemsProcess = purchaseOrderItemRepository.deleteByPoId(id)
.thenMany(Flux.fromIterable(itemsToSave)
.map(item -> { .map(item -> {
item.setId(null); item.setId(null);
item.setPoId(id); item.setPoId(id);
return item; return item;
}) })
.flatMap(purchaseOrderItemRepository::save)) .flatMap(purchaseOrderItemRepository::save))
.collectList() .collectList();
.flatMap(savedItems -> { } else {
// Update inventory for received items itemsProcess = purchaseOrderItemRepository.findByPoId(id).collectList();
return Flux.fromIterable(savedItems) }
return itemsProcess.flatMap(savedItems -> {
// 1. Resolve or create user's default inventory location
Mono<Long> locationIdMono = inventoryLocationRepository.findByUserId(userId)
.next()
.map(InventoryLocation::getId)
.switchIfEmpty(Mono.defer(() -> inventoryLocationRepository.save(InventoryLocation.builder()
.userId(userId)
.name("Main Store")
.isPrimary(true)
.createdAt(LocalDateTime.now())
.build())
.map(InventoryLocation::getId)));
// 2. Update stock & inventory items for all received items
Mono<Void> stockUpdates = locationIdMono.flatMap(locId ->
Flux.fromIterable(savedItems)
.flatMap(item -> { .flatMap(item -> {
if (item.getProductId() != null) { if (item.getProductId() != null) {
InventoryItem invItem = InventoryItem.builder() InventoryItem invItem = InventoryItem.builder()
@@ -169,48 +196,72 @@ public class PurchaseOrderService {
.grossWeight(item.getWeight()) .grossWeight(item.getWeight())
.netWeight(item.getWeight()) .netWeight(item.getWeight())
.build(); .build();
return inventoryItemService.createItem(userId, invItem)
.then(inventoryBalanceRepository.findByProductIdAndLocationId(item.getProductId(), 2L) // Default location 2L BigDecimal itemQty = (item.getWeight() != null && item.getWeight().compareTo(BigDecimal.ZERO) > 0)
? item.getWeight()
: (item.getQuantity() != null ? item.getQuantity() : BigDecimal.ONE);
Mono<InventoryBalance> balanceUpdate = inventoryBalanceRepository.findByProductIdAndLocationId(item.getProductId(), locId)
.flatMap(balance -> { .flatMap(balance -> {
balance.setQuantity(balance.getQuantity().add(item.getWeight() != null ? item.getWeight() : BigDecimal.ONE)); balance.setQuantity(balance.getQuantity().add(itemQty));
balance.setLastUpdated(LocalDateTime.now()); balance.setLastUpdated(LocalDateTime.now());
return inventoryBalanceRepository.save(balance); return inventoryBalanceRepository.save(balance);
}) })
.switchIfEmpty(Mono.defer(() -> { .switchIfEmpty(Mono.defer(() -> {
com.kifi.api.entity.inventory.InventoryBalance newBalance = new com.kifi.api.entity.inventory.InventoryBalance(); InventoryBalance newBalance = new InventoryBalance();
newBalance.setProductId(item.getProductId()); newBalance.setProductId(item.getProductId());
newBalance.setLocationId(2L); newBalance.setLocationId(locId);
newBalance.setQuantity(item.getWeight() != null ? item.getWeight() : BigDecimal.ONE); newBalance.setQuantity(itemQty);
newBalance.setLastUpdated(LocalDateTime.now()); newBalance.setLastUpdated(LocalDateTime.now());
return inventoryBalanceRepository.save(newBalance); return inventoryBalanceRepository.save(newBalance);
}))) }));
.then(Mono.just(item));
return inventoryItemService.createItem(userId, invItem)
.then(balanceUpdate)
.then();
} }
return Mono.just(item); return Mono.empty();
}) })
.then(Mono.defer(() -> vendorRepository.findById(savedPo.getVendorId()))) .then()
);
// 3. Double-entry accounting ledger transaction
Mono<Void> accountingUpdate = Mono.defer(() -> {
if (savedPo.getVendorId() == null || savedPo.getTotalAmount() == null || savedPo.getTotalAmount().compareTo(BigDecimal.ZERO) <= 0) {
return Mono.empty();
}
return vendorRepository.findById(savedPo.getVendorId())
.flatMap(vendor -> Mono.zip( .flatMap(vendor -> Mono.zip(
ledgerService.getVendorLedger(userId, vendor.getId(), vendor.getName()), ledgerService.getVendorLedger(userId, vendor.getId(), vendor.getName()),
ledgerService.getInventoryAssetLedger(userId) ledgerService.getInventoryAssetLedger(userId)
)) ))
.flatMap(ledgers -> { .flatMap(ledgers -> {
Transaction tx = Transaction.builder() com.kifi.api.entity.Transaction tx = com.kifi.api.entity.Transaction.builder()
.userId(userId) .userId(userId)
.fromWalletId(ledgers.getT1().getId()) // Vendor Payable (Liability increases via fromWallet subtraction) .fromWalletId(ledgers.getT1().getId()) // Vendor Payable (Liability increases)
.toWalletId(ledgers.getT2().getId()) // Inventory Asset (Asset increases via toWallet addition) .toWalletId(ledgers.getT2().getId()) // Inventory Asset (Asset increases)
.type("PURCHASE_RECEIPT") .type("PURCHASE_RECEIPT")
.amount(savedPo.getTotalAmount()) .amount(savedPo.getTotalAmount())
.date(java.time.LocalDate.now()) .date(java.time.LocalDate.now())
.description("Receipt of PO #" + savedPo.getPoNumber()) .description("Receipt of PO #" + savedPo.getPoNumber())
.build(); .build();
return transactionService.addTransaction(userId, tx); return transactionService.addTransaction(userId, tx).then();
}) })
.onErrorResume(err -> {
System.err.println("Accounting ledger update skipped or failed: " + err.getMessage());
return Mono.empty();
});
});
return stockUpdates
.then(accountingUpdate)
.thenReturn(savedPo) .thenReturn(savedPo)
.map(poResult -> { .map(poResult -> {
poResult.setItems(savedItems); poResult.setItems(savedItems);
return poResult; return poResult;
}); });
})); });
});
}); });
} }

View File

@@ -2,12 +2,46 @@ package com.kifi.api;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.LocalDateTime;
import com.kifi.api.repository.UserRepository;
import com.kifi.api.repository.business.BusinessProfileRepository;
import com.kifi.api.entity.User;
import com.kifi.api.entity.business.BusinessProfile;
@SpringBootTest @SpringBootTest
public class DBTest { public class DBTest {
@Autowired
private UserRepository userRepo;
@Autowired
private BusinessProfileRepository repo;
@Test @Test
public void contextLoads() { public void test() {
System.out.println("Spring Boot test context loaded successfully."); User user = new User();
user.setEmail("dbtest@kifi.app");
user.setPassword("hashedpwd");
user.setName("DB Test");
user.setSetupStatus("NOT_STARTED");
user.setProfileType("BUSINESS");
User savedUser = userRepo.save(user).block();
if (savedUser != null && savedUser.getId() != null) {
BusinessProfile bp = BusinessProfile.builder().userId(savedUser.getId()).build();
bp.setBusinessName("Test");
bp.setAddress("");
bp.setEmailId("");
bp.setPanNumber("ENC123");
bp.setGstin("ENC123");
bp.setNatureOfBusiness("JEWELLERY");
bp.setMsmeNumber("ENC123");
bp.setCreatedAt(LocalDateTime.now());
bp.setUpdatedAt(LocalDateTime.now());
repo.save(bp).block();
// Cleanup
userRepo.deleteById(savedUser.getId()).block();
}
} }
} }

16
kifi-app/.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
.git
.gitignore
.idea
.vscode
.dart_tool
build
android
ios
linux
macos
windows
test
*.md
*.iml
.DS_Store
update_add_product.py

37
kifi-app/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# Stage 1: Build Flutter Web Application
FROM ghcr.io/cirruslabs/flutter:stable AS builder
WORKDIR /app
# Build arguments for dynamic API URL and subpath base href
ARG BASE_HREF=/kifi/
ARG API_BASE_URL=https://app.technobeesolutions.in/api/kifi-v2
ENV API_BASE_URL=${API_BASE_URL}
# Copy dependency definitions first to leverage Docker layer caching
COPY pubspec.yaml pubspec.lock ./
RUN flutter pub get
# Copy source code and assets
COPY assets ./assets
COPY lib ./lib
COPY web ./web
# Build Flutter Web release bundle with specified base href
RUN flutter build web --release --no-tree-shake-icons --base-href ${BASE_HREF} --dart-define=API_BASE_URL=${API_BASE_URL}
# Stage 2: Serve with Nginx
FROM nginx:alpine
# Remove default nginx html files
RUN rm -rf /usr/share/nginx/html/*
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built web artifacts from builder stage
COPY --from=builder /app/build/web /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

29
kifi-app/build_n_push.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/bin/bash
# Configuration
REGISTRY="hub.technobeesolutions.in"
USERNAME="technobee_admin"
PASSWORD='M@tr!x#149@dm!N'
IMAGE_NAME="kifi-app"
TAG="latest"
TAG_ONE="kifi-v2"
FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME:$TAG"
FULL_IMAGE_NAME_ONE="$REGISTRY/$IMAGE_NAME:$TAG_ONE"
BASE_HREF="/kifi/"
API_URL="https://app.technobeesolutions.in/api/kifi-v2"
# Stop on any error
set -e
echo "Logging into Docker registry: $REGISTRY..."
echo "$PASSWORD" | docker login "$REGISTRY" -u "$USERNAME" --password-stdin
echo "Building and pushing Docker image for linux/amd64: $FULL_IMAGE_NAME and $FULL_IMAGE_NAME_ONE..."
docker buildx build --no-cache --platform linux/amd64 \
--build-arg BASE_HREF="$BASE_HREF" \
--build-arg API_BASE_URL="$API_URL" \
-t "$FULL_IMAGE_NAME" \
-t "$FULL_IMAGE_NAME_ONE" \
--push .
echo "Done! Image built and pushed successfully."

View File

@@ -0,0 +1,15 @@
services:
kifi-app:
image: hub.technobeesolutions.in/kifi-app:latest
container_name: kifi-app
ports:
- "${PORT:-8058}:80"
restart: unless-stopped
networks:
commons-network:
ipv4_address: 172.19.0.225
networks:
commons-network:
external: true
name: commons_commons-network

View File

@@ -1,9 +1,22 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../main.dart'; import '../../main.dart';
import '../../features/auth/presentation/auth_screen.dart'; import '../../features/auth/presentation/auth_screen.dart';
const String _envApiBaseUrl = String.fromEnvironment('API_BASE_URL', defaultValue: '');
String _getEffectiveBaseUrl() {
if (_envApiBaseUrl.isNotEmpty) {
return _envApiBaseUrl;
}
if (kIsWeb) {
return 'https://app.technobeesolutions.in/api/kifi-v2';
}
return 'https://app.technobeesolutions.in/api/kifi-v2';
}
class DioClient { class DioClient {
static final DioClient _instance = DioClient._internal(); static final DioClient _instance = DioClient._internal();
final Dio dio; final Dio dio;
@@ -26,9 +39,7 @@ class DioClient {
DioClient._internal() DioClient._internal()
: dio = Dio(BaseOptions( : dio = Dio(BaseOptions(
baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2', baseUrl: _getEffectiveBaseUrl(),
//baseUrl: 'http://192.168.0.104:8080/api/kifi-v2', // Local Mac IP for physical device testing
//baseUrl: 'http://192.168.1.5:8080/api/kifi-v2', // Current Local Mac IP (192.168.1.5)
connectTimeout: const Duration(seconds: 10), connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10),
)), )),

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
class SnackBarService { class SnackBarService {
static void showSuccess(BuildContext context, String message) { static void showSuccess(BuildContext context, String message) {

View File

@@ -0,0 +1,454 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../features/inventory/providers/commodity_rates_provider.dart';
import '../../features/business/providers/business_provider.dart';
import '../../features/business/providers/business_mode_provider.dart';
import '../../features/transactions/presentation/add_transaction_screen.dart';
import '../../features/sales/presentation/invoice_builder_screen.dart';
import '../../features/vendor/presentation/purchase_order_builder_screen.dart';
import '../../features/inventory/presentation/add_product_screen.dart';
import '../../features/auth/presentation/profile_screen.dart';
class DesktopSidebar extends ConsumerWidget {
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
const DesktopSidebar({
super.key,
required this.selectedIndex,
required this.onDestinationSelected,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isBusinessMode = ref.watch(businessModeProvider);
final businessProfile = ref.watch(businessProfileProvider).asData?.value;
final ratesAsync = ref.watch(commodityRatesProvider);
return Container(
width: 260,
decoration: BoxDecoration(
color: isDark ? const Color(0xFF131720) : Colors.white,
border: Border(
right: BorderSide(
color: isDark ? Colors.white10 : Colors.black12,
width: 1,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Brand Logo Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 16),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFD4AF37), Color(0xFFAA771C)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: const Color(0xFFD4AF37).withValues(alpha: 0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Center(
child: Icon(LucideIcons.gem, color: Colors.white, size: 22),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'KIFI',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
letterSpacing: 1.2,
color: isDark ? Colors.white : const Color(0xFF1E293B),
),
),
Text(
businessProfile?.businessName ?? 'Financial Ledger',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white54 : Colors.black45,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
// 2. Quick Action Button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: PopupMenuButton<String>(
onSelected: (action) {
switch (action) {
case 'transaction':
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AddTransactionScreen()),
);
break;
case 'invoice':
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const InvoiceBuilderScreen()),
);
break;
case 'po':
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const PurchaseOrderBuilderScreen()),
);
break;
case 'product':
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AddProductScreen()),
);
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'transaction',
child: Row(
children: [
Icon(LucideIcons.arrowDownUp, size: 18, color: Colors.blueAccent),
SizedBox(width: 12),
Text('New Transaction'),
],
),
),
if (isBusinessMode) ...[
const PopupMenuItem(
value: 'invoice',
child: Row(
children: [
Icon(LucideIcons.fileText, size: 18, color: Colors.green),
SizedBox(width: 12),
Text('New Sales Invoice'),
],
),
),
const PopupMenuItem(
value: 'po',
child: Row(
children: [
Icon(LucideIcons.shoppingBag, size: 18, color: Colors.purpleAccent),
SizedBox(width: 12),
Text('New Purchase Order'),
],
),
),
const PopupMenuItem(
value: 'product',
child: Row(
children: [
Icon(LucideIcons.packagePlus, size: 18, color: Color(0xFFD4AF37)),
SizedBox(width: 12),
Text('Add Inventory Product'),
],
),
),
],
],
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 14),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isDark
? [const Color(0xFF2563EB), const Color(0xFF1D4ED8)]
: [const Color(0xFF3B82F6), const Color(0xFF2563EB)],
),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: const Color(0xFF2563EB).withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.plus, color: Colors.white, size: 18),
SizedBox(width: 8),
Text(
'Create New',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
Spacer(),
Icon(LucideIcons.chevronDown, color: Colors.white70, size: 16),
],
),
),
),
),
const SizedBox(height: 8),
// 3. Navigation List
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 12),
children: [
_buildNavItem(
context,
index: 0,
icon: LucideIcons.layoutDashboard,
label: 'Dashboard',
isSelected: selectedIndex == 0,
),
_buildNavItem(
context,
index: 1,
icon: LucideIcons.arrowDownUp,
label: 'Transactions',
isSelected: selectedIndex == 1,
),
_buildNavItem(
context,
index: 2,
icon: LucideIcons.wallet,
label: 'Wallets & Accounts',
isSelected: selectedIndex == 2,
),
_buildNavItem(
context,
index: 3,
icon: LucideIcons.target,
label: 'Budgets & Goals',
isSelected: selectedIndex == 3,
),
if (isBusinessMode) ...[
const Padding(
padding: EdgeInsets.fromLTRB(12, 20, 12, 8),
child: Text(
'BUSINESS & ERP',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 1.1,
color: Colors.grey,
),
),
),
_buildNavItem(
context,
index: 4,
icon: LucideIcons.briefcase,
label: 'Business Hub',
isSelected: selectedIndex == 4,
),
],
],
),
),
// 4. Live Metal Rate Widget in Sidebar
ratesAsync.when(
data: (rates) {
if (rates.isEmpty) return const SizedBox.shrink();
final goldRate = rates.firstWhere(
(r) => r.commodityCode == 'GOLD',
orElse: () => rates.first,
);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E2330) : const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: const Color(0xFFD4AF37).withValues(alpha: 0.2),
),
),
child: Row(
children: [
const Icon(LucideIcons.coins, color: Color(0xFFD4AF37), size: 18),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Gold (24K/10g)',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white60 : Colors.black54,
),
),
Text(
'${(goldRate.rate * 10).toStringAsFixed(0)}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Color(0xFFD4AF37),
),
),
],
),
),
const Icon(LucideIcons.trendingUp, color: Colors.green, size: 16),
],
),
);
},
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
),
// 5. Sidebar Footer (Profile & Settings)
Container(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 16),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: isDark ? Colors.white10 : Colors.black12,
),
),
),
child: Row(
children: [
Expanded(
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ProfileScreen()),
);
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(4),
child: Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: isDark ? const Color(0xFF2563EB) : const Color(0xFF3B82F6),
child: const Icon(LucideIcons.user, size: 16, color: Colors.white),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Profile & Settings',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
),
IconButton(
tooltip: 'Settings',
icon: const Icon(LucideIcons.settings, size: 18),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ProfileScreen()),
);
},
),
],
),
),
],
),
);
}
Widget _buildNavItem(
BuildContext context, {
required int index,
required IconData icon,
required String label,
required bool isSelected,
}) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: InkWell(
onTap: () => onDestinationSelected(index),
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: isSelected
? (isDark
? const Color(0xFF2563EB).withValues(alpha: 0.18)
: const Color(0xFF2563EB).withValues(alpha: 0.1))
: Colors.transparent,
borderRadius: BorderRadius.circular(10),
border: isSelected
? Border.all(
color: const Color(0xFF2563EB).withValues(alpha: 0.3),
)
: null,
),
child: Row(
children: [
Icon(
icon,
size: 19,
color: isSelected
? const Color(0xFF3B82F6)
: (isDark ? Colors.white60 : Colors.black54),
),
const SizedBox(width: 12),
Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected
? (isDark ? Colors.white : const Color(0xFF1D4ED8))
: (isDark ? Colors.white70 : Colors.black87),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
class ResponsiveBreakpoints {
static const double mobile = 768.0;
static const double desktop = 1024.0;
static const double wideDesktop = 1440.0;
}
class ResponsiveLayout {
static bool isMobile(BuildContext context) =>
MediaQuery.sizeOf(context).width < ResponsiveBreakpoints.mobile;
static bool isTablet(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
return width >= ResponsiveBreakpoints.mobile && width < ResponsiveBreakpoints.desktop;
}
static bool isDesktop(BuildContext context) =>
MediaQuery.sizeOf(context).width >= ResponsiveBreakpoints.mobile;
static bool isWideDesktop(BuildContext context) =>
MediaQuery.sizeOf(context).width >= ResponsiveBreakpoints.wideDesktop;
}
class MaxContentWidth extends StatelessWidget {
final Widget child;
final double maxWidth;
final EdgeInsetsGeometry padding;
const MaxContentWidth({
super.key,
required this.child,
this.maxWidth = 1200.0,
this.padding = EdgeInsets.zero,
});
@override
Widget build(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Padding(
padding: padding,
child: child,
),
),
);
}
}
class ResponsiveBuilder extends StatelessWidget {
final Widget Function(BuildContext context) mobile;
final Widget Function(BuildContext context)? tablet;
final Widget Function(BuildContext context) desktop;
const ResponsiveBuilder({
super.key,
required this.mobile,
this.tablet,
required this.desktop,
});
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
if (width >= ResponsiveBreakpoints.desktop) {
return desktop(context);
} else if (width >= ResponsiveBreakpoints.mobile) {
return (tablet ?? desktop)(context);
} else {
return mobile(context);
}
}
}

View File

@@ -33,11 +33,10 @@ class SmartSearchDropdown<T> extends StatefulWidget {
} }
class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> { class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
final OverlayPortalController _overlayController = OverlayPortalController();
final LayerLink _layerLink = LayerLink(); final LayerLink _layerLink = LayerLink();
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
final TextEditingController _controller = TextEditingController(); final TextEditingController _controller = TextEditingController();
OverlayEntry? _overlayEntry;
bool _showAll = false;
List<T> _filteredItems = []; List<T> _filteredItems = [];
@override @override
@@ -49,15 +48,10 @@ class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
} }
_focusNode.addListener(() { _focusNode.addListener(() {
if (_focusNode.hasFocus) { if (_focusNode.hasFocus) {
_showOverlay(); setState(() {
} else { _filteredItems = widget.items;
_removeOverlay(); });
// Reset text to selected value if focus lost without selection _overlayController.show();
if (widget.value != null) {
_controller.text = widget.itemAsString(widget.value as T);
} else {
_controller.text = '';
}
} }
}); });
} }
@@ -81,7 +75,6 @@ class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
void dispose() { void dispose() {
_focusNode.dispose(); _focusNode.dispose();
_controller.dispose(); _controller.dispose();
_removeOverlay();
super.dispose(); super.dispose();
} }
@@ -98,58 +91,73 @@ class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
}).toList(); }).toList();
} }
}); });
_overlayEntry?.markNeedsBuild();
} }
void _showOverlay() { void _hideOverlay() {
_removeOverlay(); _overlayController.hide();
_showAll = true; if (widget.value != null) {
_filteredItems = widget.items; _controller.text = widget.itemAsString(widget.value as T);
_overlayEntry = _createOverlayEntry(); } else {
Overlay.of(context).insert(_overlayEntry!); _controller.text = '';
}
} }
void _removeOverlay() { @override
_overlayEntry?.remove(); Widget build(BuildContext context) {
_overlayEntry = null; return CompositedTransformTarget(
} link: _layerLink,
child: TapRegion(
groupId: _layerLink,
child: OverlayPortal(
controller: _overlayController,
overlayChildBuilder: (BuildContext overlayContext) {
final renderBox = context.findRenderObject() as RenderBox?;
final width = (renderBox != null && renderBox.hasSize) ? renderBox.size.width : 300.0;
OverlayEntry _createOverlayEntry() { return CompositedTransformFollower(
RenderBox renderBox = context.findRenderObject() as RenderBox;
var size = renderBox.size;
return OverlayEntry(
builder: (context) => Positioned(
width: size.width,
child: CompositedTransformFollower(
link: _layerLink, link: _layerLink,
showWhenUnlinked: false, showWhenUnlinked: false,
offset: Offset(0.0, size.height + 5.0), targetAnchor: Alignment.bottomLeft,
followerAnchor: Alignment.topLeft,
offset: const Offset(0.0, 6.0),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: width,
child: TapRegion(
groupId: _layerLink,
onTapOutside: (event) {
_focusNode.unfocus();
_hideOverlay();
},
child: Material( child: Material(
elevation: 4.0, elevation: 8.0,
borderRadius: BorderRadius.circular(8.0), shadowColor: Colors.black26,
borderRadius: BorderRadius.circular(16.0),
color: Theme.of(context).cardColor, color: Theme.of(context).cardColor,
child: StatefulBuilder( clipBehavior: Clip.antiAlias,
builder: (context, setOverlayState) { child: Container(
final displayItems = _showAll ? _filteredItems : (_filteredItems.isNotEmpty ? [_filteredItems.first] : <T>[]); constraints: const BoxConstraints(maxHeight: 280),
decoration: BoxDecoration(
return Container( borderRadius: BorderRadius.circular(16.0),
constraints: const BoxConstraints(maxHeight: 250), border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.15),
),
),
child: _filteredItems.isEmpty
? Padding(
padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (displayItems.isEmpty) const Text('No matches found', style: TextStyle(color: Colors.grey)),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
const Text('No matches found'),
if (widget.emptyActionText != null && widget.onEmptyActionPressed != null) if (widget.emptyActionText != null && widget.onEmptyActionPressed != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: TextButton( child: TextButton(
onPressed: () { onPressed: () {
_focusNode.unfocus(); _focusNode.unfocus();
_overlayController.hide();
widget.onEmptyActionPressed!(); widget.onEmptyActionPressed!();
}, },
child: Text(widget.emptyActionText!), child: Text(widget.emptyActionText!),
@@ -158,88 +166,90 @@ class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
], ],
), ),
) )
else : ListView.separated(
Flexible(
child: ListView.builder(
shrinkWrap: true, shrinkWrap: true,
padding: EdgeInsets.zero, padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: displayItems.length, itemCount: _filteredItems.length,
separatorBuilder: (context, i) => Divider(
height: 1,
color: Theme.of(context).dividerColor.withValues(alpha: 0.1),
),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = displayItems[index]; final item = _filteredItems[index];
final isSelected = widget.value == item;
return InkWell( return InkWell(
onTap: () { onTap: () {
widget.onChanged(item); widget.onChanged(item);
_controller.text = widget.itemAsString(item); _controller.text = widget.itemAsString(item);
_focusNode.unfocus(); _focusNode.unfocus();
_overlayController.hide();
}, },
child: Container(
color: isSelected
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1)
: null,
child: widget.itemBuilder != null child: widget.itemBuilder != null
? widget.itemBuilder!(context, item) ? widget.itemBuilder!(context, item)
: Padding( : Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 14.0),
child: Text(widget.itemAsString(item)), child: Text(
widget.itemAsString(item),
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
),
),
),
), ),
); );
}, },
), ),
), ),
if (!_showAll && _controller.text.isEmpty && widget.items.length > 1) ),
InkWell( ),
onTap: () { ),
setOverlayState(() { ),
_showAll = true; );
});
}, },
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: Colors.grey.withOpacity(0.2))),
),
child: const Text(
'Show all',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.blue, fontWeight: FontWeight.bold),
),
),
),
],
),
);
}
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _layerLink,
child: TextFormField( child: TextFormField(
controller: _controller, controller: _controller,
focusNode: _focusNode, focusNode: _focusNode,
decoration: InputDecoration( decoration: InputDecoration(
labelText: widget.labelText, labelText: widget.labelText,
labelStyle: TextStyle(color: Theme.of(context).brightness == Brightness.dark ? Colors.white70 : Colors.grey[600]),
hintText: widget.hintText, hintText: widget.hintText,
suffixIcon: const Icon(Icons.arrow_drop_down, color: Colors.grey), suffixIcon: IconButton(
filled: Theme.of(context).inputDecorationTheme.filled ?? true, icon: Icon(
fillColor: widget.fillColor ?? Theme.of(context).inputDecorationTheme.fillColor ?? (Theme.of(context).brightness == Brightness.dark ? Colors.black26 : Colors.grey[100]), _overlayController.isShowing ? Icons.arrow_drop_up : Icons.arrow_drop_down,
border: Theme.of(context).inputDecorationTheme.border ?? OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), color: Colors.grey,
enabledBorder: Theme.of(context).inputDecorationTheme.enabledBorder ?? OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: Theme.of(context).inputDecorationTheme.focusedBorder ?? OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), onPressed: () {
if (_overlayController.isShowing) {
_focusNode.unfocus();
_overlayController.hide();
} else {
_focusNode.requestFocus();
_filterItems(_controller.text);
_overlayController.show();
}
},
), ),
),
onTap: () {
if (!_overlayController.isShowing) {
_filterItems(_controller.text);
_overlayController.show();
}
},
onChanged: (val) { onChanged: (val) {
_showAll = true; // when user types, we want to show all matching results if (!_overlayController.isShowing) {
_overlayController.show();
}
_filterItems(val); _filterItems(val);
}, },
), ),
),
),
); );
} }
} }

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import 'reset_password_screen.dart'; import 'reset_password_screen.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../../dashboard/presentation/dashboard_screen.dart'; import '../../dashboard/presentation/dashboard_screen.dart';
import 'setup_wizard_screen.dart'; import 'setup_wizard_screen.dart';

View File

@@ -3,7 +3,7 @@ import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:cross_file/cross_file.dart'; import 'package:cross_file/cross_file.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import 'auth_screen.dart'; import 'auth_screen.dart';

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl_phone_field/intl_phone_field.dart'; import 'package:intl_phone_field/intl_phone_field.dart';
import '../../../../core/network/dio_client.dart'; import '../../../../core/network/dio_client.dart';
import '../../dashboard/presentation/dashboard_screen.dart'; import '../../dashboard/presentation/dashboard_screen.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../transactions/providers/providers.dart'; import '../../transactions/providers/providers.dart';
import '../../transactions/data/models.dart'; import '../../transactions/data/models.dart';
import '../../../core/widgets/shimmer_loading.dart'; import '../../../core/widgets/shimmer_loading.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../../core/theme/nature_colors.dart'; import '../../../../core/theme/nature_colors.dart';
import '../../../inventory/presentation/product_list_screen.dart'; import '../../../inventory/presentation/product_list_screen.dart';
import '../../../inventory/presentation/daily_rates_screen.dart'; import '../../../inventory/presentation/daily_rates_screen.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../inventory/providers/inventory_valuation_provider.dart'; import '../../../inventory/providers/inventory_valuation_provider.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../providers/business_provider.dart'; import '../../providers/business_provider.dart';
class BusinessSettingsScreen extends ConsumerStatefulWidget { class BusinessSettingsScreen extends ConsumerStatefulWidget {

View File

@@ -1,11 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../transactions/providers/providers.dart'; import '../../transactions/providers/providers.dart';
import '../../transactions/data/models.dart'; import '../../transactions/data/models.dart';
import '../../transactions/data/repository.dart'; import '../../transactions/data/repository.dart';
import 'wallet_ledger_screen.dart'; import 'wallet_ledger_screen.dart';
import '../../../core/widgets/responsive_layout.dart';
class AccountsScreen extends ConsumerStatefulWidget { class AccountsScreen extends ConsumerStatefulWidget {
final String? initialFilterNature; final String? initialFilterNature;
@@ -449,7 +450,9 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
final walletsState = ref.watch(walletProvider); final walletsState = ref.watch(walletProvider);
return Scaffold( return Scaffold(
body: SafeArea( body: MaxContentWidth(
maxWidth: 1200,
child: SafeArea(
bottom: false, bottom: false,
child: Column( child: Column(
children: [ children: [
@@ -939,6 +942,7 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
], ],
), ),
), ),
),
); );
} }
} }

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../transactions/data/models.dart'; import '../../transactions/data/models.dart';
import '../../transactions/providers/providers.dart'; import '../../transactions/providers/providers.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../transactions/providers/providers.dart'; import '../../../transactions/providers/providers.dart';
class BudgetStatusCard extends ConsumerWidget { class BudgetStatusCard extends ConsumerWidget {

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../transactions/data/models.dart'; import '../../../transactions/data/models.dart';
import '../../../../core/theme/nature_colors.dart'; import '../../../../core/theme/nature_colors.dart';
import 'day_wise_spending_chart.dart'; import 'day_wise_spending_chart.dart';

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
class CarouselItemData { class CarouselItemData {
final String title; final String title;

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../transactions/providers/providers.dart'; import '../../../transactions/providers/providers.dart';
import '../../../transactions/data/models.dart'; import '../../../transactions/data/models.dart';

View File

@@ -85,7 +85,14 @@ class Product {
0.0, 0.0,
isActive: json['isActive'] ?? json['is_active'] ?? true, isActive: json['isActive'] ?? json['is_active'] ?? true,
imageIds: json['images'] != null imageIds: json['images'] != null
? (json['images'] as List).map((i) => i['id'] as int).toList() ? (json['images'] as List).map((i) {
if (i is Map) {
return (i['id'] as num).toInt();
} else if (i is num) {
return i.toInt();
}
return 0;
}).where((id) => id > 0).toList()
: [], : [],
currentStock: currentStock:
(json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ?? (json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ??

View File

@@ -1,14 +1,17 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage; import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage;
import '../../../core/network/dio_client.dart'; import '../../../core/network/dio_client.dart';
import '../../../../core/widgets/smart_search_dropdown.dart'; import '../../../../core/widgets/smart_search_dropdown.dart';
import '../../../core/widgets/responsive_layout.dart';
import '../domain/product.dart'; import '../domain/product.dart';
import '../providers/products_provider.dart'; import '../providers/products_provider.dart';
import '../providers/product_categories_provider.dart'; import '../providers/product_categories_provider.dart';
import '../providers/uoms_provider.dart';
import '../../business/providers/business_provider.dart'; import '../../business/providers/business_provider.dart';
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
@@ -113,8 +116,12 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
} }
} }
for (final file in _images) { for (final file in _images) {
if (kIsWeb) {
allImages.add(NetworkImage(file.path));
} else {
allImages.add(FileImage(File(file.path))); allImages.add(FileImage(File(file.path)));
} }
}
Navigator.push( Navigator.push(
context, context,
@@ -169,6 +176,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final categoriesState = ref.watch(productCategoriesProvider); final categoriesState = ref.watch(productCategoriesProvider);
final uomsState = ref.watch(uomsProvider);
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
@@ -177,7 +185,10 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
), ),
body: GestureDetector( body: MaxContentWidth(
maxWidth: 900,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusScope.of(context).unfocus(), onTap: () => FocusScope.of(context).unfocus(),
child: Column( child: Column(
children: [ children: [
@@ -209,16 +220,21 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
const Text('Category & Unit', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const Text('Category & Unit', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12), const SizedBox(height: 12),
categoriesState.when( categoriesState.when(
loading: () => const CircularProgressIndicator(), loading: () => const Padding(
error: (err, stack) => Text('Error loading categories: $err'), padding: EdgeInsets.symmetric(vertical: 8),
child: LinearProgressIndicator(),
),
error: (err, stack) => Text('Error loading categories: $err', style: const TextStyle(color: Colors.red)),
data: (categories) { data: (categories) {
final leafCategories = categories.where((c) => !categories.any((child) => child.parentCategoryId == c.id)).toList(); final activeCategories = categories.where((c) => c.isActive).toList();
return leafCategories.isEmpty final uoms = uomsState.value ?? [];
return activeCategories.isEmpty
? const Text('No categories found. Manage categories in Business Hub.', style: TextStyle(color: Colors.orange)) ? const Text('No categories found. Manage categories in Business Hub.', style: TextStyle(color: Colors.orange))
: SmartSearchDropdown<ProductCategory>( : SmartSearchDropdown<ProductCategory>(
hintText: 'Select Category*', hintText: 'Select Category*',
value: _selectedCategory, value: _selectedCategory,
items: leafCategories, items: activeCategories,
itemAsString: (c) => c.name, itemAsString: (c) => c.name,
itemBuilder: (context, item) { itemBuilder: (context, item) {
List<String> path = []; List<String> path = [];
@@ -232,11 +248,12 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
} }
} }
return Padding( return Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(item.name, style: const TextStyle(fontWeight: FontWeight.bold)), Text(item.name, style: const TextStyle(fontWeight: FontWeight.bold)),
if (path.length > 1)
Text(path.join(' -> '), style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), Text(path.join(' -> '), style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
], ],
), ),
@@ -258,6 +275,15 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
if (val.makingChargeType != null && val.makingChargeType!.isNotEmpty) { if (val.makingChargeType != null && val.makingChargeType!.isNotEmpty) {
_makingChargesType = val.makingChargeType!; _makingChargesType = val.makingChargeType!;
} }
if (_uomId == null && uoms.isNotEmpty) {
final match = uoms.where((u) =>
u.abbreviation?.toLowerCase() == val.baseUnit.toLowerCase() ||
u.name.toLowerCase() == val.baseUnit.toLowerCase()
).firstOrNull;
if (match != null) {
_uomId = match.id;
}
}
} }
}); });
}, },
@@ -265,15 +291,28 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<int>( uomsState.when(
decoration: const InputDecoration(labelText: 'Unit of Measure (Optional)'), loading: () => const Padding(
value: _uomId, padding: EdgeInsets.symmetric(vertical: 8),
items: const [ child: LinearProgressIndicator(),
DropdownMenuItem(value: 1, child: Text('Grams (g)')), ),
DropdownMenuItem(value: 2, child: Text('Kilograms (kg)')), error: (err, stack) => Text('Error loading units: $err', style: const TextStyle(color: Colors.red)),
DropdownMenuItem(value: 3, child: Text('Pieces (pcs)')), data: (uoms) {
], final effectiveValue = uoms.any((u) => u.id == _uomId) ? _uomId : null;
return DropdownButtonFormField<int>(
decoration: const InputDecoration(
labelText: 'Unit of Measure (Optional)',
),
isExpanded: true,
hint: const Text('None (Optional)'),
value: effectiveValue,
items: uoms.map((u) => DropdownMenuItem<int>(
value: u.id,
child: Text(u.displayName),
)).toList(),
onChanged: (val) => setState(() => _uomId = val), onChanged: (val) => setState(() => _uomId = val),
);
},
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -314,6 +353,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
Expanded( Expanded(
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
value: _makingChargesType, value: _makingChargesType,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Charge Type'), decoration: const InputDecoration(labelText: 'Charge Type'),
items: const [ items: const [
DropdownMenuItem(value: 'PER_GRAM', child: Text('Per Gram')), DropdownMenuItem(value: 'PER_GRAM', child: Text('Per Gram')),
@@ -364,7 +404,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final localIndex = index - _existingImageIds.length; final localIndex = index - _existingImageIds.length;
return _buildImageThumbnail( return _buildImageThumbnail(
isNetwork: false, isNetwork: false,
file: File(_images[localIndex].path), xFile: _images[localIndex],
onTap: () => _previewImage(index), onTap: () => _previewImage(index),
onDelete: () => _removeImage(localIndex), onDelete: () => _removeImage(localIndex),
); );
@@ -379,8 +419,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 24), padding: const EdgeInsets.symmetric(vertical: 24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.05), color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.05),
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.3)), border: Border.all(color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3)),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Column( child: Column(
@@ -403,6 +443,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
], ],
), ),
), ),
),
); );
} }
@@ -428,7 +469,13 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
); );
} }
Widget _buildImageThumbnail({required bool isNetwork, String? url, File? file, required VoidCallback onDelete, required VoidCallback onTap}) { Widget _buildImageThumbnail({
required bool isNetwork,
String? url,
XFile? xFile,
required VoidCallback onDelete,
required VoidCallback onTap,
}) {
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -437,7 +484,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.withOpacity(0.2)), border: Border.all(color: Colors.grey.withValues(alpha: 0.2)),
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -452,7 +499,9 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
headers: {'Authorization': 'Bearer $_token'}, headers: {'Authorization': 'Bearer $_token'},
errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey), errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey),
)) ))
: Image.file(file!, fit: BoxFit.cover), : (kIsWeb
? Image.network(xFile!.path, fit: BoxFit.cover)
: Image.file(File(xFile!.path), fit: BoxFit.cover)),
), ),
), ),
), ),

View File

@@ -4,7 +4,7 @@ import '../providers/product_categories_provider.dart';
import '../../../core/theme/nature_colors.dart'; import '../../../core/theme/nature_colors.dart';
import '../../business/providers/business_mode_provider.dart'; import '../../business/providers/business_mode_provider.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'dart:ui'; import 'dart:ui';
import '../../../core/utils/purity_utils.dart'; import '../../../core/utils/purity_utils.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../providers/product_categories_provider.dart'; import '../providers/product_categories_provider.dart';
import '../providers/commodity_rates_provider.dart'; import '../providers/commodity_rates_provider.dart';

View File

@@ -1,12 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:async'; import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../providers/products_provider.dart'; import '../providers/products_provider.dart';
import '../providers/product_categories_provider.dart'; import '../providers/product_categories_provider.dart';
import 'add_product_screen.dart'; import 'add_product_screen.dart';
import 'product_detail_screen.dart'; import 'product_detail_screen.dart';
import '../../../core/network/dio_client.dart'; import '../../../core/network/dio_client.dart';
import '../../../core/widgets/responsive_layout.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class ProductListScreen extends ConsumerStatefulWidget { class ProductListScreen extends ConsumerStatefulWidget {
@@ -66,7 +67,9 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
foregroundColor: Colors.black, foregroundColor: Colors.black,
centerTitle: true, centerTitle: true,
), ),
body: Column( body: MaxContentWidth(
maxWidth: 1000,
child: Column(
children: [ children: [
Container( Container(
color: Colors.white, color: Colors.white,
@@ -138,13 +141,10 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
} }
final p = products[index]; final p = products[index];
final catName = final catName = categoriesState.value
categoriesState.value ?.where((c) => c.id == p.categoryId)
?.firstWhere( .firstOrNull
(c) => c.id == p.categoryId, ?.name ??
orElse: () => categoriesState.value!.first,
)
.name ??
'No Category'; 'No Category';
return Container( return Container(
@@ -267,12 +267,16 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
), ),
], ],
), ),
),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () { onPressed: () async {
Navigator.push( await Navigator.push(
context, context,
MaterialPageRoute(builder: (_) => const AddProductScreen()), MaterialPageRoute(builder: (_) => const AddProductScreen()),
); );
if (mounted) {
ref.read(productsProvider.notifier).refresh();
}
}, },
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(LucideIcons.plus, color: Colors.white), child: const Icon(LucideIcons.plus, color: Colors.white),

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:async'; import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:kifi_app/features/inventory/providers/products_provider.dart'; import 'package:kifi_app/features/inventory/providers/products_provider.dart';
import 'package:kifi_app/features/inventory/presentation/widgets/adjust_stock_sheet.dart'; import 'package:kifi_app/features/inventory/presentation/widgets/adjust_stock_sheet.dart';
import 'package:kifi_app/core/network/dio_client.dart'; import 'package:kifi_app/core/network/dio_client.dart';

View File

@@ -7,7 +7,7 @@ import 'package:kifi_app/features/inventory/providers/product_categories_provide
import 'package:kifi_app/features/inventory/providers/commodity_rates_provider.dart'; import 'package:kifi_app/features/inventory/providers/commodity_rates_provider.dart';
import 'package:kifi_app/core/network/dio_client.dart'; import 'package:kifi_app/core/network/dio_client.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:kifi_app/features/vendor/presentation/purchase_order_details_screen.dart'; import 'package:kifi_app/features/vendor/presentation/purchase_order_details_screen.dart';
import 'package:kifi_app/features/vendor/providers/purchase_orders_provider.dart'; import 'package:kifi_app/features/vendor/providers/purchase_orders_provider.dart';
import 'package:kifi_app/core/utils/purity_utils.dart'; import 'package:kifi_app/core/utils/purity_utils.dart';

View File

@@ -10,6 +10,7 @@ import '../domain/inventory_item.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:flutter/foundation.dart';
class ProductsNotifier extends AsyncNotifier<List<Product>> { class ProductsNotifier extends AsyncNotifier<List<Product>> {
int _currentPage = 0; int _currentPage = 0;
@@ -25,71 +26,74 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
return _fetchProducts(page: _currentPage, size: _pageSize); return _fetchProducts(page: _currentPage, size: _pageSize);
} }
Future<List<Product>> _fetchProducts({ Future<List<Product>> _fetchProducts({int page = 0, int size = 50, String? search}) async {
required int page, try {
required int size, final queryParams = <String, dynamic>{
}) async {
final response = await DioClient().dio.get(
'/inventory/products',
queryParameters: {
'page': page, 'page': page,
'size': size, 'size': size,
if (_currentSearchQuery.isNotEmpty) 'search': _currentSearchQuery, };
}, if (search != null && search.isNotEmpty) {
); queryParams['search'] = search;
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
if (data.isNotEmpty) {
try {
final file = File(
'/Users/maddy/Projects/Kifi/kifi-app/debug_product.json',
);
await file.writeAsString(jsonEncode(data.first));
} catch (_) {}
} }
final products = data.map((e) => Product.fromJson(e)).toList();
if (products.length < size) { final response = await DioClient().dio.get(
_hasMore = false; '/inventory/products',
} queryParameters: queryParams,
return products; );
if (response.data is List) {
final List list = response.data;
_hasMore = list.length >= size;
return list.map((json) => Product.fromJson(json as Map<String, dynamic>)).toList();
} else if (response.data is Map && response.data['content'] != null) {
final List list = response.data['content'];
_hasMore = !(response.data['last'] ?? true);
return list.map((json) => Product.fromJson(json as Map<String, dynamic>)).toList();
} }
return []; return [];
} } catch (e) {
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 { Future<void> refresh() async {
state = const AsyncValue.loading();
_currentPage = 0; _currentPage = 0;
_hasMore = true; _hasMore = true;
try { state = const AsyncValue.loading();
state = AsyncValue.data( state = await AsyncValue.guard(() => _fetchProducts(page: 0, size: _pageSize, search: _currentSearchQuery));
await _fetchProducts(page: _currentPage, size: _pageSize),
);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
} }
Future<void> search(String query) async { Future<void> search(String query) async {
_currentSearchQuery = query; _currentSearchQuery = query;
await refresh(); _currentPage = 0;
_hasMore = true;
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _fetchProducts(page: 0, size: _pageSize, search: query));
}
Future<void> fetchNextPage() async {
await loadMore();
}
Future<void> loadMore() async {
if (!_hasMore || _isLoadingMore || state.isLoading) return;
_isLoadingMore = true;
try {
final nextPage = _currentPage + 1;
final newItems = await _fetchProducts(page: nextPage, size: _pageSize, search: _currentSearchQuery);
if (newItems.isNotEmpty) {
_currentPage = nextPage;
state = AsyncValue.data([...state.value ?? [], ...newItems]);
} else {
_hasMore = false;
}
} catch (e) {
// Handle silently or notify
} finally {
_isLoadingMore = false;
}
} }
Future<void> createProduct(Product product, {List<XFile>? images}) async { Future<void> createProduct(Product product, {List<XFile>? images}) async {
@@ -103,15 +107,20 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
final productId = response.data['id']; final productId = response.data['id'];
for (var image in images) { for (var image in images) {
final bytes = await image.readAsBytes(); final bytes = await image.readAsBytes();
final compressedBytes = await FlutterImageCompress.compressWithList( Uint8List uploadBytes = bytes;
if (!kIsWeb) {
try {
uploadBytes = await FlutterImageCompress.compressWithList(
bytes, bytes,
minWidth: 800, minWidth: 800,
quality: 80, quality: 80,
); );
} catch (_) {}
}
final formData = FormData.fromMap({ final formData = FormData.fromMap({
'file': MultipartFile.fromBytes( 'file': MultipartFile.fromBytes(
compressedBytes, uploadBytes,
filename: image.name, filename: image.name,
), ),
}); });
@@ -143,15 +152,20 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
if (newImages != null && newImages.isNotEmpty) { if (newImages != null && newImages.isNotEmpty) {
for (var image in newImages) { for (var image in newImages) {
final bytes = await image.readAsBytes(); final bytes = await image.readAsBytes();
final compressedBytes = await FlutterImageCompress.compressWithList( Uint8List uploadBytes = bytes;
if (!kIsWeb) {
try {
uploadBytes = await FlutterImageCompress.compressWithList(
bytes, bytes,
minWidth: 800, minWidth: 800,
quality: 80, quality: 80,
); );
} catch (_) {}
}
final formData = FormData.fromMap({ final formData = FormData.fromMap({
'file': MultipartFile.fromBytes( 'file': MultipartFile.fromBytes(
compressedBytes, uploadBytes,
filename: image.name, filename: image.name,
), ),
}); });

View File

@@ -0,0 +1,102 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/dio_client.dart';
class UnitOfMeasure {
final int? id;
final int? userId;
final String name;
final String? abbreviation;
UnitOfMeasure({
this.id,
this.userId,
required this.name,
this.abbreviation,
});
factory UnitOfMeasure.fromJson(Map<String, dynamic> json) {
return UnitOfMeasure(
id: json['id'],
userId: json['userId'] ?? json['user_id'],
name: json['name'] ?? '',
abbreviation: json['abbreviation'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'name': name,
'abbreviation': abbreviation,
};
}
String get displayName => abbreviation != null && abbreviation!.isNotEmpty ? '$name ($abbreviation)' : name;
}
class UomsNotifier extends AsyncNotifier<List<UnitOfMeasure>> {
@override
FutureOr<List<UnitOfMeasure>> build() async {
return _fetchUoms();
}
Future<List<UnitOfMeasure>> _fetchUoms() async {
try {
final response = await DioClient().dio.get('/inventory/uom');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
final list = data.map((e) => UnitOfMeasure.fromJson(e)).toList();
if (list.isNotEmpty) {
return list;
}
}
} catch (_) {}
// If empty in database, seed standard units of measure
try {
final defaults = [
{'name': 'Grams', 'abbreviation': 'g'},
{'name': 'Kilograms', 'abbreviation': 'kg'},
{'name': 'Pieces', 'abbreviation': 'pcs'},
{'name': 'Carats', 'abbreviation': 'ct'},
{'name': 'Milligrams', 'abbreviation': 'mg'},
];
for (final def in defaults) {
await DioClient().dio.post('/inventory/uom', data: def);
}
final response = await DioClient().dio.get('/inventory/uom');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((e) => UnitOfMeasure.fromJson(e)).toList();
}
} catch (_) {}
return [];
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _fetchUoms());
}
Future<UnitOfMeasure?> createUom(String name, String abbreviation) async {
try {
final response = await DioClient().dio.post(
'/inventory/uom',
data: {'name': name, 'abbreviation': abbreviation},
);
if (response.statusCode == 200 || response.statusCode == 201) {
final newUom = UnitOfMeasure.fromJson(response.data);
await refresh();
return newUom;
}
} catch (_) {}
return null;
}
}
final uomsProvider = AsyncNotifierProvider<UomsNotifier, List<UnitOfMeasure>>(() {
return UomsNotifier();
});

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../dashboard/presentation/dashboard_screen.dart'; import '../../dashboard/presentation/dashboard_screen.dart';
class OnboardingScreen extends StatefulWidget { class OnboardingScreen extends StatefulWidget {

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../domain/project.dart'; import '../domain/project.dart';
import '../providers/project_provider.dart'; import '../providers/project_provider.dart';
import 'widgets/add_task_sheet.dart'; import 'widgets/add_task_sheet.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../../core/theme/nature_colors.dart'; import '../../../../core/theme/nature_colors.dart';
import 'projects_screen.dart'; import 'projects_screen.dart';
import '../../sales/presentation/invoices_list_screen.dart'; import '../../sales/presentation/invoices_list_screen.dart';

View File

@@ -1,4 +1,4 @@
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/project_provider.dart'; import '../providers/project_provider.dart';

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../domain/project.dart'; import '../../domain/project.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../sales/presentation/add_customer_sheet.dart'; import '../../../sales/presentation/add_customer_sheet.dart';
import '../../providers/project_provider.dart'; import '../../providers/project_provider.dart';
import '../../../sales/providers/customers_provider.dart'; import '../../../sales/providers/customers_provider.dart';

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import '../../../../core/widgets/premium_text_field.dart'; import '../../../../core/widgets/premium_text_field.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../domain/project_task.dart'; import '../../domain/project_task.dart';
import '../../providers/project_provider.dart'; import '../../providers/project_provider.dart';
import '../../providers/user_provider.dart'; import '../../providers/user_provider.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../domain/project_task.dart'; import '../../domain/project_task.dart';
import '../../providers/project_provider.dart'; import '../../providers/project_provider.dart';
import 'task_details_sheet.dart'; import 'task_details_sheet.dart';

View File

@@ -2,7 +2,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../providers/customers_provider.dart'; import '../providers/customers_provider.dart';
import '../domain/customer.dart'; import '../domain/customer.dart';
import 'dart:async'; import 'dart:async';
@@ -222,7 +223,7 @@ class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
radius: 50, radius: 50,
backgroundColor: Colors.grey[200], backgroundColor: Colors.grey[200],
backgroundImage: _photo != null backgroundImage: _photo != null
? FileImage(File(_photo!.path)) as ImageProvider ? (kIsWeb ? NetworkImage(_photo!.path) : FileImage(File(_photo!.path))) as ImageProvider
: (widget.customer?.photoUrl != null && : (widget.customer?.photoUrl != null &&
_token != null _token != null
? NetworkImage( ? NetworkImage(

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../providers/customers_provider.dart'; import '../providers/customers_provider.dart';
import 'dart:async'; import 'dart:async';
import '../../../core/widgets/shimmer_loading.dart'; import '../../../core/widgets/shimmer_loading.dart';

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@@ -710,7 +711,7 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => AttachmentGalleryScreen( builder: (_) => AttachmentGalleryScreen(
images: [FileImage(File(_invoiceFile!.path))], images: [kIsWeb ? NetworkImage(_invoiceFile!.path) : FileImage(File(_invoiceFile!.path))],
initialIndex: 0, initialIndex: 0,
), ),
), ),

View File

@@ -2,7 +2,7 @@ import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:screenshot/screenshot.dart'; import 'package:screenshot/screenshot.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../providers/invoices_provider.dart'; import '../providers/invoices_provider.dart';
import '../domain/invoice.dart'; import '../domain/invoice.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../../core/widgets/premium_text_field.dart'; import '../../../../core/widgets/premium_text_field.dart';
import '../../../../core/widgets/smart_search_dropdown.dart'; import '../../../../core/widgets/smart_search_dropdown.dart';
import '../../providers/customers_provider.dart'; import '../../providers/customers_provider.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../../core/widgets/premium_text_field.dart'; import '../../../../core/widgets/premium_text_field.dart';
import '../../../transactions/providers/providers.dart'; import '../../../transactions/providers/providers.dart';
import '../../providers/invoices_provider.dart'; import '../../providers/invoices_provider.dart';

View File

@@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
@@ -90,15 +92,20 @@ class CustomersNotifier extends AsyncNotifier<List<Customer>> {
Future<void> _uploadPhoto(int customerId, XFile photo) async { Future<void> _uploadPhoto(int customerId, XFile photo) async {
final bytes = await photo.readAsBytes(); final bytes = await photo.readAsBytes();
final compressedBytes = await FlutterImageCompress.compressWithList( Uint8List uploadBytes = bytes;
if (!kIsWeb) {
try {
uploadBytes = await FlutterImageCompress.compressWithList(
bytes, bytes,
minWidth: 413, minWidth: 413,
minHeight: 531, minHeight: 531,
quality: 85, quality: 85,
); );
} catch (_) {}
}
final formData = FormData.fromMap({ final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(compressedBytes, filename: photo.name), 'file': MultipartFile.fromBytes(uploadBytes, filename: photo.name),
}); });
await DioClient().dio.post('/customers/$customerId/photo', data: formData); await DioClient().dio.post('/customers/$customerId/photo', data: formData);

View File

@@ -1,12 +1,16 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'widgets/attachment_gallery_screen.dart'; import 'widgets/attachment_gallery_screen.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as flutter_secure_storage; import 'package:flutter_secure_storage/flutter_secure_storage.dart' as flutter_secure_storage;
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart';
import '../../../core/network/dio_client.dart';
import '../../../core/widgets/responsive_layout.dart';
import '../providers/providers.dart'; import '../providers/providers.dart';
import '../data/models.dart'; import '../data/models.dart';
import '../../../core/utils/snackbar_service.dart'; import '../../../core/utils/snackbar_service.dart';
@@ -133,11 +137,16 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
final XFile? image = await _picker.pickImage(source: source); final XFile? image = await _picker.pickImage(source: source);
if (image != null) { if (image != null) {
final bytes = await image.readAsBytes(); final bytes = await image.readAsBytes();
final compressedBytes = await FlutterImageCompress.compressWithList( Uint8List compressedBytes = bytes;
if (!kIsWeb) {
try {
compressedBytes = await FlutterImageCompress.compressWithList(
bytes, bytes,
minWidth: 600, minWidth: 600,
quality: 80, quality: 80,
); );
} catch (_) {}
}
final base64String = base64Encode(compressedBytes); final base64String = base64Encode(compressedBytes);
setState(() { setState(() {
base64Attachments.add({ base64Attachments.add({
@@ -751,7 +760,9 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
) )
], ],
), ),
body: SafeArea( body: MaxContentWidth(
maxWidth: 800,
child: SafeArea(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
child: Column( child: Column(
@@ -988,7 +999,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
image: DecorationImage( image: DecorationImage(
image: _jwtToken != null image: _jwtToken != null
? NetworkImage('https://app.technobeesolutions.in/api/kifi-v2/transactions/${widget.transaction!.id}/attachments/${att.id}/content', headers: {'Authorization': 'Bearer $_jwtToken'}) ? NetworkImage('${DioClient().dio.options.baseUrl}/transactions/${widget.transaction!.id}/attachments/${att.id}/content', headers: {'Authorization': 'Bearer $_jwtToken'})
: const AssetImage('assets/images/placeholder.png') as ImageProvider, : const AssetImage('assets/images/placeholder.png') as ImageProvider,
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
@@ -1096,6 +1107,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
), ),
), ),
), ),
),
); );
} }
@@ -1105,7 +1117,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
// Add existing attachments // Add existing attachments
for (final att in existingAttachments) { for (final att in existingAttachments) {
if (_jwtToken != null) { if (_jwtToken != null) {
allImages.add(NetworkImage('https://app.technobeesolutions.in/api/kifi-v2/transactions/${widget.transaction!.id}/attachments/${att.id}/content', headers: {'Authorization': 'Bearer $_jwtToken'})); allImages.add(NetworkImage('${DioClient().dio.options.baseUrl}/transactions/${widget.transaction!.id}/attachments/${att.id}/content', headers: {'Authorization': 'Bearer $_jwtToken'}));
} else { } else {
allImages.add(const AssetImage('assets/images/placeholder.png')); allImages.add(const AssetImage('assets/images/placeholder.png'));
} }

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../providers/providers.dart'; import '../providers/providers.dart';
import '../../dashboard/presentation/maturity_dialog.dart'; import '../../dashboard/presentation/maturity_dialog.dart';
@@ -10,6 +10,7 @@ import '../../../core/widgets/empty_state.dart';
import 'add_transaction_screen.dart'; import 'add_transaction_screen.dart';
import '../presentation/widgets/transaction_filter_sheet.dart'; import '../presentation/widgets/transaction_filter_sheet.dart';
import '../../../core/theme/nature_colors.dart'; import '../../../core/theme/nature_colors.dart';
import '../../../core/widgets/responsive_layout.dart';
class AllTransactionsScreen extends ConsumerStatefulWidget { class AllTransactionsScreen extends ConsumerStatefulWidget {
const AllTransactionsScreen({super.key}); const AllTransactionsScreen({super.key});
@@ -40,7 +41,6 @@ class _AllTransactionsScreenState extends ConsumerState<AllTransactionsScreen> {
} }
void _onSearchChanged(String value) { void _onSearchChanged(String value) {
// Basic debounce for text search
Future.delayed(const Duration(milliseconds: 500), () { Future.delayed(const Duration(milliseconds: 500), () {
if (_searchController.text == value) { if (_searchController.text == value) {
ref.read(paginatedTransactionProvider.notifier).updateFilters(search: value); ref.read(paginatedTransactionProvider.notifier).updateFilters(search: value);
@@ -88,13 +88,15 @@ class _AllTransactionsScreenState extends ConsumerState<AllTransactionsScreen> {
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search transactions...', hintText: 'Search transactions...',
prefixIcon: const Icon(LucideIcons.search), prefixIcon: const Icon(LucideIcons.search),
suffixIcon: _searchController.text.isNotEmpty ? IconButton( suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(LucideIcons.x), icon: const Icon(LucideIcons.x),
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
_onSearchChanged(''); _onSearchChanged('');
}, },
) : null, )
: null,
filled: true, filled: true,
fillColor: Colors.grey.shade100, fillColor: Colors.grey.shade100,
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -103,7 +105,7 @@ class _AllTransactionsScreenState extends ConsumerState<AllTransactionsScreen> {
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2) borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2),
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
), ),
@@ -111,7 +113,9 @@ class _AllTransactionsScreenState extends ConsumerState<AllTransactionsScreen> {
), ),
), ),
), ),
body: Builder( body: MaxContentWidth(
maxWidth: 1200,
child: Builder(
builder: (context) { builder: (context) {
if (transactions.isEmpty && paginatedState.isLoading) { if (transactions.isEmpty && paginatedState.isLoading) {
return ListView.builder( return ListView.builder(
@@ -249,7 +253,7 @@ class _AllTransactionsScreenState extends ConsumerState<AllTransactionsScreen> {
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete', style: TextStyle(color: Colors.red)) child: const Text('Delete', style: TextStyle(color: Colors.red)),
), ),
], ],
), ),
@@ -261,9 +265,7 @@ class _AllTransactionsScreenState extends ConsumerState<AllTransactionsScreen> {
}, },
child: Card( child: Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
child: Column( child: ListTile(
children: [
ListTile(
onTap: () { onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => AddTransactionScreen(transaction: t))); Navigator.push(context, MaterialPageRoute(builder: (_) => AddTransactionScreen(transaction: t)));
}, },
@@ -294,14 +296,13 @@ class _AllTransactionsScreenState extends ConsumerState<AllTransactionsScreen> {
], ],
), ),
), ),
],
),
), ),
); );
}, },
), ),
); );
} },
),
), ),
); );
} }

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../providers/providers.dart'; import '../../providers/providers.dart';
import '../../providers/paginated_transaction_provider.dart'; import '../../providers/paginated_transaction_provider.dart';

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../providers/vendors_provider.dart'; import '../providers/vendors_provider.dart';
import '../domain/vendor.dart'; import '../domain/vendor.dart';
import 'dart:async'; import 'dart:async';
@@ -209,7 +210,7 @@ class _AddVendorSheetState extends ConsumerState<AddVendorSheet> {
radius: 50, radius: 50,
backgroundColor: Colors.blue.withOpacity(0.1), backgroundColor: Colors.blue.withOpacity(0.1),
backgroundImage: _photo != null backgroundImage: _photo != null
? FileImage(File(_photo!.path)) as ImageProvider ? (kIsWeb ? NetworkImage(_photo!.path) : FileImage(File(_photo!.path))) as ImageProvider
: (widget.vendor?.photoUrl != null : (widget.vendor?.photoUrl != null
? NetworkImage(widget.vendor!.photoUrl!) ? NetworkImage(widget.vendor!.photoUrl!)
: null), : null),

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../core/widgets/premium_text_field.dart'; import '../../../core/widgets/premium_text_field.dart';
import '../../../core/widgets/smart_search_dropdown.dart'; import '../../../core/widgets/smart_search_dropdown.dart';

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@@ -897,7 +898,7 @@ class _PurchaseOrderBuilderScreenState
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => AttachmentGalleryScreen( builder: (_) => AttachmentGalleryScreen(
images: [FileImage(File(_invoiceFile!.path))], images: [kIsWeb ? NetworkImage(_invoiceFile!.path) : FileImage(File(_invoiceFile!.path))],
initialIndex: 0, initialIndex: 0,
), ),
), ),
@@ -1362,7 +1363,7 @@ class _POItemRowState extends State<_POItemRow> {
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => AttachmentGalleryScreen( builder: (_) => AttachmentGalleryScreen(
images: [FileImage(File(widget.item.localPhotoPath!))], images: [kIsWeb ? NetworkImage(widget.item.localPhotoPath!) : FileImage(File(widget.item.localPhotoPath!))],
initialIndex: 0, initialIndex: 0,
), ),
), ),
@@ -1396,7 +1397,9 @@ class _POItemRowState extends State<_POItemRow> {
child: widget.item.localPhotoPath != null child: widget.item.localPhotoPath != null
? ClipRRect( ? ClipRRect(
borderRadius: BorderRadius.circular(7), borderRadius: BorderRadius.circular(7),
child: Image.file(File(widget.item.localPhotoPath!), fit: BoxFit.cover), child: kIsWeb
? Image.network(widget.item.localPhotoPath!, fit: BoxFit.cover)
: Image.file(File(widget.item.localPhotoPath!), fit: BoxFit.cover),
) )
: widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty : widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty
? ClipRRect( ? ClipRRect(

View File

@@ -1,7 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../providers/purchase_orders_provider.dart'; import '../providers/purchase_orders_provider.dart';
import '../providers/vendors_provider.dart'; import '../providers/vendors_provider.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../providers/vendors_provider.dart'; import '../providers/vendors_provider.dart';
import 'add_vendor_sheet.dart'; import 'add_vendor_sheet.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../../core/widgets/premium_text_field.dart'; import '../../../../core/widgets/premium_text_field.dart';
import '../../../../core/widgets/smart_search_dropdown.dart'; import '../../../../core/widgets/smart_search_dropdown.dart';
import '../../providers/vendors_provider.dart'; import '../../providers/vendors_provider.dart';

View File

@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/dio_client.dart'; import '../../../core/network/dio_client.dart';
import '../domain/purchase_order.dart'; import '../domain/purchase_order.dart';
import '../domain/purchase_payment.dart'; import '../domain/purchase_payment.dart';
import '../../inventory/providers/products_provider.dart';
class PurchaseOrdersNotifier extends AsyncNotifier<List<PurchaseOrder>> { class PurchaseOrdersNotifier extends AsyncNotifier<List<PurchaseOrder>> {
@override @override
@@ -84,6 +85,7 @@ class PurchaseOrdersNotifier extends AsyncNotifier<List<PurchaseOrder>> {
state = AsyncValue.data( state = AsyncValue.data(
current.map((c) => c.id == updatedPo.id ? updatedPo : c).toList(), current.map((c) => c.id == updatedPo.id ? updatedPo : c).toList(),
); );
ref.invalidate(productsProvider);
return updatedPo; return updatedPo;
} }
throw Exception('Failed to receive purchase invoice'); throw Exception('Failed to receive purchase invoice');

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@@ -132,7 +133,7 @@ class _KifiAppState extends ConsumerState<KifiApp> {
? Scaffold(body: Center(child: Padding(padding: const EdgeInsets.all(20), child: Text("Startup Error: $_startupError", style: const TextStyle(color: Colors.red))))) ? Scaffold(body: Center(child: Padding(padding: const EdgeInsets.all(20), child: Text("Startup Error: $_startupError", style: const TextStyle(color: Colors.red)))))
: _isLoading : _isLoading
? const Scaffold(body: Center(child: CircularProgressIndicator())) ? const Scaffold(body: Center(child: CircularProgressIndicator()))
: (!_hasSeenOnboarding : ((!kIsWeb && !_hasSeenOnboarding)
? const OnboardingScreen() ? const OnboardingScreen()
: (_isAuthenticated : (_isAuthenticated
? (_isSetupCompleted ? const DashboardScreen() : const SetupWizardScreen()) ? (_isSetupCompleted ? const DashboardScreen() : const SetupWizardScreen())

55
kifi-app/nginx.conf Normal file
View File

@@ -0,0 +1,55 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Support /kifi/ subpath if forwarded with prefix by reverse proxy
rewrite ^/kifi/(.*)$ /$1 break;
rewrite ^/kifi$ / break;
# Gzip compression for Flutter Web assets (JS, WASM, CSS, JSON, Fonts)
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/x-javascript
application/wasm
image/svg+xml
font/woff
font/woff2;
# Service Worker & manifest: no cache to ensure immediate updates
location ~* (flutter_service_worker\.js|manifest\.json|version\.json)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
try_files $uri =404;
}
# Static assets (images, fonts, canvaskit, icons, js chunks): long cache
location ~* \.(?:ico|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|wasm)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Main SPA routing: fall back to index.html for all subroutes
location / {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri $uri/ /index.html;
}
# Error pages
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

View File

@@ -808,14 +808,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
lucide_icons: lucide_icons_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
name: lucide_icons name: lucide_icons_flutter
sha256: ad24d0fd65707e48add30bebada7d90bff2a1bba0a72d6e9b19d44246b0e83c4 sha256: "6e2eb4a11137851be84a57e936fc864d565709ec0ae854b786b15397d1b66276"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.257.0" version: "3.1.17"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:

View File

@@ -40,7 +40,6 @@ dependencies:
fl_chart: ^1.2.0 fl_chart: ^1.2.0
google_fonts: ^8.2.1 google_fonts: ^8.2.1
intl: ^0.20.3 intl: ^0.20.3
lucide_icons: ^0.257.0
local_auth: ^3.0.2 local_auth: ^3.0.2
path_provider: ^2.1.6 path_provider: ^2.1.6
share_plus: ^13.3.0 share_plus: ^13.3.0
@@ -60,6 +59,7 @@ dependencies:
file_picker: ^12.0.0 file_picker: ^12.0.0
intl_phone_field: ^3.2.0 intl_phone_field: ^3.2.0
url_launcher: ^6.3.2 url_launcher: ^6.3.2
lucide_icons_flutter: ^3.1.17
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -1,38 +1,95 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF"> <base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project."> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="description" content="KIFI - Modern Financial Ledger & Jewellery Business Suite">
<!-- iOS meta tags & icons --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="kifi_app"> <meta name="apple-mobile-web-app-title" content="KIFI">
<link rel="apple-touch-icon" href="icons/Icon-192.png"> <link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/> <link rel="icon" type="image/png" href="favicon.png"/>
<title>kifi_app</title> <title>KIFI - Financial Ledger & Jewellery ERP</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<style>
body {
margin: 0;
padding: 0;
background-color: #0b0e14;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100vw;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow: hidden;
}
.splash-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.splash-logo {
width: 64px;
height: 64px;
border-radius: 16px;
background: linear-gradient(135deg, #D4AF37 0%, #AA771C 100%);
box-shadow: 0 8px 32px rgba(212, 175, 55, 0.35);
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 28px;
font-weight: bold;
animation: pulse 2s infinite ease-in-out;
}
.splash-title {
color: #ffffff;
font-size: 20px;
font-weight: 800;
letter-spacing: 2px;
margin-top: 18px;
opacity: 0.9;
}
.splash-sub {
color: #8892b0;
font-size: 12px;
letter-spacing: 0.5px;
margin-top: 6px;
}
.spinner {
margin-top: 28px;
width: 32px;
height: 32px;
border: 3px solid rgba(212, 175, 55, 0.2);
border-radius: 50%;
border-top-color: #D4AF37;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
</style>
</head> </head>
<body> <body>
<div class="splash-container">
<div class="splash-logo">💎</div>
<div class="splash-title">KIFI</div>
<div class="splash-sub">Financial Ledger & Jewellery ERP</div>
<div class="spinner"></div>
</div>
<script src="flutter_bootstrap.js" async></script> <script src="flutter_bootstrap.js" async></script>
</body> </body>
</html> </html>