Revamp stage one - individual account and account setup fixed

This commit is contained in:
2026-08-25 19:45:24 +05:30
parent 82c1a891c0
commit 53c1f62373
52 changed files with 3980 additions and 4020 deletions

BIN
.DS_Store vendored

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +0,0 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
public class TestDeserialization {
public static class CreateWalletRequest {
public BigDecimal initialBalance;
}
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
CreateWalletRequest req = mapper.readValue("{\"initialBalance\": 50000.0}", CreateWalletRequest.class);
System.out.println("Parsed: " + req.initialBalance);
CreateWalletRequest req2 = mapper.readValue("{\"initialBalance\": 50000}", CreateWalletRequest.class);
System.out.println("Parsed: " + req2.initialBalance);
}
}

View File

@@ -1,113 +0,0 @@
# Kifi V2 Phase 1 - Inventory Management Complete Implementation Plan
This document outlines the complete architecture and task breakdown required to finish Phase 1 (Inventory Management), incorporating all feedback including full UOM CRUD, GST taxation logic, and Bill of Materials (BOM).
## User Review Required
> [!IMPORTANT]
> **Stock Movements vs. Direct Edits**
> Moving forward, users will *not* directly edit the `quantity` of a product in the "Edit Product" screen. All stock adjustments will go through the new **Inventory Movements** mechanism (Add, Reduce, Adjust) to ensure a historically accurate Stock Ledger.
>
> **Bill of Materials (BOM) Consumption:**
> When a BOM/Bundle product is sold or assembled, the system must automatically deduct the stock of its underlying raw materials. We will implement "Assembly Movements" to handle this cleanly.
---
## 1. Product Detail & Ledger (Presentation Layer)
A read-only rich product view that replaces the immediate navigation to the Edit screen.
#### [NEW] `lib/features/inventory/presentation/product_detail_screen.dart`
- **Purpose:** Display a rich overview of the product.
- **Sections:**
- Header: Image carousel, Name, SKU, Status.
- Stock Summary: Available stock vs. Reserved stock.
- Pricing & Taxes: Purchase vs Selling price, GST configuration.
- Action Bar: "Adjust Stock", "Edit Product", "Share".
#### [NEW] `lib/features/inventory/presentation/stock_ledger_tab.dart`
- **Purpose:** A chronological list of all movements in and out of the warehouse for a given product.
- **Data:** Display Date, Reference (e.g. "Opening", "Adjustment"), In/Out arrows, and Running Balance.
---
## 2. Inventory Movements (Domain & API Layer)
The core backend and frontend logic for tracking stock changes robustly.
#### [NEW] `lib/features/inventory/domain/stock_movement.dart`
- **Purpose:** Data model representing an atomic inventory transaction.
- **Fields:** `id`, `productId`, `type` (OPENING, ADDITION, REDUCTION, ADJUSTMENT, DAMAGE, ASSEMBLY), `quantity`, `balanceAfter`, `date`, `notes`.
#### [MODIFY] `lib/features/inventory/providers/products_provider.dart`
- **Changes:** Add methods for `adjustStock(productId, type, quantity, notes)` and `fetchStockLedger(productId)`.
#### [NEW] `kifi-api/src/main/java/com/kifi/api/entity/inventory/StockMovement.java` (Backend)
- **Purpose:** The PostgreSQL entity for tracking historical stock changes.
---
## 3. Bill of Materials (BOM) / Bundle of Materials
Allowing products to be composed of other raw materials or sub-products.
#### [NEW] `kifi-api/src/main/java/com/kifi/api/entity/inventory/ProductBomItem.java`
- **Purpose:** Backend entity mapping a parent product to its component products with required quantities.
- **Fields:** `id`, `parentProductId`, `componentProductId`, `quantityRequired`.
#### [NEW] `lib/features/inventory/domain/bom_item.dart`
- **Purpose:** Frontend model for BOM components.
#### [NEW] `lib/features/inventory/presentation/bom/manage_bom_screen.dart`
- **Purpose:** UI for a user to select a product and add component products to it to define its recipe/bundle.
- **Functionality:** Search products, specify quantities, and save the BOM structure.
---
## 4. Business Profile & GST Configuration
The context wrapper for the Business Mode, heavily incorporating Indian GST logic.
#### [NEW] `lib/features/business/presentation/settings/business_profile_screen.dart`
- **Purpose:** UI to capture Business Registration, Industry Type, Tax Numbers (GSTIN), and Business Logo.
- **GST Logic Implementation:**
- Store the business's Home State.
- When calculating taxes on transactions/invoices, compare the customer's state with the business's state.
- **Intra-state:** Split total GST equally into SGST (9%) and CGST (9%).
- **Inter-state:** Apply full tax to IGST (18%).
#### [NEW] `lib/features/business/domain/tax_calculator.dart`
- **Purpose:** A utility to handle the SGST/CGST vs IGST split logic cleanly based on state codes.
#### [MODIFY] `lib/features/business/providers/business_provider.dart`
- **Changes:** Cache the active `BusinessProfile` object to make the Home State instantly available for tax calculations.
---
## 5. Units of Measure (UOM) Management (Full CRUD)
Master data UI for inventory sizing, completely managed by the user.
#### [NEW] `lib/features/inventory/presentation/uom/uom_list_screen.dart`
- **Purpose:** View all existing UOMs.
#### [NEW] `lib/features/inventory/presentation/uom/add_edit_uom_screen.dart`
- **Purpose:** Full CRUD capabilities. Users can create custom units (e.g., "Box", "Kg", "Dozen"), update their names, or delete them (if not linked to existing products).
#### [NEW] `lib/features/inventory/providers/uom_provider.dart`
- **Purpose:** Manage the state of UOMs (create, update, delete, fetch).
---
## 6. Inventory Dashboard & Alerts
Bringing the data to life on the Business Hub.
#### [MODIFY] `lib/features/business/presentation/hub/business_hub_screen.dart`
- **Changes:** Replace static placeholders with real widgets powered by providers:
- **Total Value Card:** Aggregated `quantity * purchasePrice`.
- **Low Stock Widget:** List of products where `currentStock <= minStock`.
---
## Verification Plan
### Manual Verification
1. **Product Detail & Movements:** Create a product, perform an "Add Stock" adjustment of 10, then a "Reduce Stock" of 2. Verify the stock ledger shows accurate running balances (10, then 8).
2. **UOM CRUD:** Create a new custom UOM ("Pallet"), verify it appears in the dropdown when creating a product, then edit its name.
3. **BOM Logic:** Create raw materials A and B. Create Final Product C with a BOM of 1xA + 2xB. Assemble 1 unit of Product C and verify A's stock reduces by 1 and B's stock reduces by 2.
4. **GST Logic:** Set Business State to "Maharashtra". Create a mock sale to "Maharashtra" and verify SGST/CGST split. Create a mock sale to "Delhi" and verify IGST mapping.

View File

@@ -48,6 +48,7 @@ public class SecurityConfig {
.pathMatchers("/api/kifi-v2/auth/**").permitAll()
.pathMatchers("/api/kifi-v2/health/**").permitAll()
.pathMatchers("/api/kifi-v2/wallets/test/**").permitAll()
.pathMatchers("/public/legal/**").permitAll()
.anyExchange().authenticated()
)
.build();

View File

@@ -0,0 +1,38 @@
package com.kifi.api.controller;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/public/legal")
public class LegalController {
@GetMapping(value = "/terms", produces = MediaType.TEXT_HTML_VALUE)
public Mono<ResponseEntity<String>> getTerms() {
return loadResource("policies/terms-and-conditions.html");
}
@GetMapping(value = "/privacy", produces = MediaType.TEXT_HTML_VALUE)
public Mono<ResponseEntity<String>> getPrivacy() {
return loadResource("policies/privacy-policy.html");
}
private Mono<ResponseEntity<String>> loadResource(String path) {
return Mono.fromCallable(() -> {
ClassPathResource resource = new ClassPathResource(path);
if (resource.exists()) {
String content = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8);
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(content);
}
return ResponseEntity.notFound().build();
});
}
}

View File

@@ -0,0 +1,45 @@
package com.kifi.api.controller;
import com.kifi.api.dto.SetupRequestDTO;
import com.kifi.api.service.SetupService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;
import java.util.Map;
@RestController
@RequestMapping("/api/kifi-v2/account")
@RequiredArgsConstructor
public class SetupController {
private final SetupService setupService;
@GetMapping("/setup/status")
public Mono<ResponseEntity<Map<String, String>>> getSetupStatus(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return setupService.getSetupStatus(userId)
.map(ResponseEntity::ok);
}
@GetMapping("/username/availability")
public Mono<ResponseEntity<Map<String, Boolean>>> isUsernameAvailable(@RequestParam String username) {
return setupService.isUsernameAvailable(username)
.map(available -> ResponseEntity.ok(Map.of("available", available)));
}
@PostMapping("/setup")
public Mono<ResponseEntity<Map<String, String>>> completeSetup(Authentication authentication, @RequestBody SetupRequestDTO request, org.springframework.web.server.ServerWebExchange exchange) {
Long userId = Long.valueOf(authentication.getDetails().toString());
String userAgent = exchange.getRequest().getHeaders().getFirst("User-Agent");
String ipAddress = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For");
if (ipAddress == null && exchange.getRequest().getRemoteAddress() != null) {
ipAddress = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
}
return setupService.completeSetup(userId, request, ipAddress, userAgent)
.map(message -> ResponseEntity.ok(Map.of("message", message)))
.onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("message", e.getMessage()))));
}
}

View File

@@ -0,0 +1,23 @@
package com.kifi.api.dto;
import lombok.Data;
@Data
public class SetupRequestDTO {
private String accountType;
private String name;
private String username;
private String mobileNumber;
private String natureOfBusiness;
private String businessName;
private String address;
private Long stateId;
private String emailId;
private String gstin;
private String panNumber;
private String msmeNumber;
private Boolean termsAccepted;
private Boolean privacyAccepted;
}

View File

@@ -23,6 +23,12 @@ public class User {
private Boolean enabled = false;
@Builder.Default
private String profileType = "INDIVIDUAL";
private String username;
private String name;
private String mobileNumber;
@Builder.Default
private String setupStatus = "NOT_STARTED";
private LocalDateTime setupCompletedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,29 @@
package com.kifi.api.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table("user_consents")
public class UserConsent {
@Id
private Long id;
private Long userId;
private String consentType;
private String policyVersion;
@Builder.Default
private Boolean accepted = true;
private LocalDateTime acceptedAt;
private String ipAddress;
private String userAgent;
private LocalDateTime createdAt;
}

View File

@@ -30,6 +30,8 @@ public class BusinessProfile {
private String emailId;
private String panNumber;
private String gstin;
private String natureOfBusiness;
private String msmeNumber;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,9 @@
package com.kifi.api.repository;
import com.kifi.api.entity.UserConsent;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Mono;
public interface UserConsentRepository extends ReactiveCrudRepository<UserConsent, Long> {
Mono<UserConsent> findByUserIdAndConsentTypeAndPolicyVersion(Long userId, String consentType, String policyVersion);
}

View File

@@ -11,4 +11,7 @@ import reactor.core.publisher.Mono;
public interface UserRepository extends R2dbcRepository<User, Long> {
Mono<User> findByEmail(String email);
Flux<User> findByEmailContainingIgnoreCase(String email);
Mono<User> findByUsername(String username);
Mono<User> findByEmailOrUsername(String email, String username);
}

View File

@@ -104,7 +104,7 @@ public class AuthService {
String email = decryptField(request.getEmail());
String password = decryptField(request.getPassword());
return userRepository.findByEmail(email)
return userRepository.findByEmailOrUsername(email, email)
.switchIfEmpty(Mono.error(new RuntimeException("Invalid email or password")))
.flatMap(user -> {
if (!user.getEnabled()) {

View File

@@ -41,7 +41,7 @@ public class CryptoService {
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
return new String(decryptedBytes, "UTF-8");
} catch (Exception e) {
log.error("Failed to decrypt RSA payload", e);
log.warn("Failed to decrypt RSA payload: {}", e.getMessage());
throw new RuntimeException("Failed to decrypt payload");
}
}

View File

@@ -0,0 +1,114 @@
package com.kifi.api.service;
import com.kifi.api.dto.SetupRequestDTO;
import com.kifi.api.entity.User;
import com.kifi.api.entity.UserConsent;
import com.kifi.api.entity.business.BusinessProfile;
import com.kifi.api.repository.UserConsentRepository;
import com.kifi.api.repository.UserRepository;
import com.kifi.api.repository.business.BusinessProfileRepository;
import com.kifi.api.util.CryptoUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
@Slf4j
public class SetupService {
private final UserRepository userRepository;
private final BusinessProfileRepository businessProfileRepository;
private final UserConsentRepository userConsentRepository;
private final CryptoUtils cryptoUtils;
public Mono<java.util.Map<String, String>> getSetupStatus(Long userId) {
return userRepository.findById(userId)
.map(u -> {
java.util.Map<String, String> map = new java.util.HashMap<>();
map.put("status", u.getSetupStatus() != null ? u.getSetupStatus() : "NOT_STARTED");
map.put("profileType", u.getProfileType() != null ? u.getProfileType() : "INDIVIDUAL");
map.put("name", u.getName() != null ? u.getName() : "");
map.put("email", u.getEmail() != null ? u.getEmail() : "");
return map;
})
.defaultIfEmpty(java.util.Map.of("status", "NOT_STARTED", "profileType", "INDIVIDUAL"));
}
public Mono<Boolean> isUsernameAvailable(String username) {
return userRepository.findByUsername(username)
.hasElement()
.map(exists -> !exists);
}
@Transactional
public Mono<String> completeSetup(Long userId, SetupRequestDTO request, String ipAddress, String userAgent) {
return userRepository.findById(userId)
.switchIfEmpty(Mono.error(new RuntimeException("User not found")))
.flatMap(user -> {
user.setUsername(request.getUsername());
user.setName(request.getName());
user.setMobileNumber(cryptoUtils.encrypt(request.getMobileNumber()));
user.setProfileType(request.getAccountType());
user.setSetupStatus("COMPLETED");
user.setSetupCompletedAt(LocalDateTime.now());
user.setUpdatedAt(LocalDateTime.now());
Mono<User> userSaveMono = userRepository.save(user);
Mono<BusinessProfile> businessProfileMono;
if ("BUSINESS".equalsIgnoreCase(request.getAccountType())) {
businessProfileMono = businessProfileRepository.findByUserId(userId)
.defaultIfEmpty(BusinessProfile.builder().userId(userId).build())
.flatMap(bp -> {
bp.setBusinessName(request.getBusinessName() != null ? request.getBusinessName() : request.getName());
bp.setNatureOfBusiness(request.getNatureOfBusiness());
bp.setAddress(request.getAddress());
bp.setStateId(request.getStateId());
bp.setEmailId(request.getEmailId());
bp.setGstin(cryptoUtils.encrypt(request.getGstin()));
bp.setPanNumber(cryptoUtils.encrypt(request.getPanNumber()));
bp.setMsmeNumber(cryptoUtils.encrypt(request.getMsmeNumber()));
bp.setCreatedAt(bp.getCreatedAt() == null ? LocalDateTime.now() : bp.getCreatedAt());
bp.setUpdatedAt(LocalDateTime.now());
return businessProfileRepository.save(bp);
});
} else {
businessProfileMono = Mono.just(new BusinessProfile());
}
Mono<UserConsent> termsConsent = userConsentRepository.save(
UserConsent.builder()
.userId(userId)
.consentType("TERMS_AND_CONDITIONS")
.policyVersion("1.0")
.accepted(request.getTermsAccepted())
.acceptedAt(LocalDateTime.now())
.ipAddress(ipAddress)
.userAgent(userAgent)
.createdAt(LocalDateTime.now())
.build()
);
Mono<UserConsent> privacyConsent = userConsentRepository.save(
UserConsent.builder()
.userId(userId)
.consentType("PRIVACY_POLICY")
.policyVersion("1.0")
.accepted(request.getPrivacyAccepted())
.acceptedAt(LocalDateTime.now())
.ipAddress(ipAddress)
.userAgent(userAgent)
.createdAt(LocalDateTime.now())
.build()
);
return Mono.zip(userSaveMono, businessProfileMono, termsConsent, privacyConsent)
.map(tuple -> "Setup completed successfully");
});
}
}

View File

@@ -0,0 +1,46 @@
package com.kifi.api.util;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
@Component
public class CryptoUtils {
private final String secret;
public CryptoUtils(@Value("${app.encryption.secret:404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970}") String secret) {
// Ensure the secret is at least 16 bytes for AES
this.secret = secret.substring(0, 16);
}
public String encrypt(String plainText) {
if (plainText == null) return null;
try {
SecretKeySpec key = new SecretKeySpec(secret.getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] encryptedBytes = cipher.doFinal(plainText.getBytes());
return Base64.getEncoder().encodeToString(encryptedBytes);
} catch (Exception e) {
throw new RuntimeException("Error encrypting data", e);
}
}
public String decrypt(String encryptedText) {
if (encryptedText == null) return null;
try {
SecretKeySpec key = new SecretKeySpec(secret.getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.DECRYPT_MODE, key);
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedText));
return new String(decryptedBytes);
} catch (Exception e) {
// Might not be encrypted or key changed, return original or throw. For safety, return as is if decoding fails (e.g., existing unencrypted data).
return encryptedText;
}
}
}

View File

@@ -15,7 +15,7 @@ spring:
sql:
init:
mode: never
mode: always
schema-locations: classpath:schema.sql
data:

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<title>Privacy Policy</title>
<style>body { font-family: sans-serif; padding: 20px; line-height: 1.6; color: #333; }</style>
</head>
<body>
<h1>Privacy Policy</h1>
<p>Last updated: August 25, 2026</p>
<h2>1. Information Collected</h2>
<p>We collect personal information such as your name, username, and email. We may optionally collect your mobile number. For business accounts, we collect business names, addresses, tax identifiers (PAN, GSTIN), and MSME details.</p>
<h2>2. Purpose of Processing</h2>
<p>We use this information to provide the Kifi service, facilitate accounting and invoice generation, and manage your user account.</p>
<h2>3. Encryption and Security</h2>
<p>Sensitive Personally Identifiable Information (PII) such as Mobile Numbers, PAN, GSTIN, and MSME numbers are encrypted at rest in our database. We mask these identifiers in the application interface unless legally required (e.g., on an invoice).</p>
<h2>4. Data Storage and Sharing</h2>
<p>Your data is securely stored. We do not sell your personal information. We may share data with third-party service providers acting on our behalf, or when legally compelled.</p>
<h2>5. User Rights</h2>
<p>You have the right to access, update, or request deletion of your personal data through the application settings or by contacting our support.</p>
<h2>6. Consent</h2>
<p>By completing the account setup, you explicitly consent to the collection and processing of your data as described in this policy.</p>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<title>Terms and Conditions</title>
<style>body { font-family: sans-serif; padding: 20px; line-height: 1.6; color: #333; }</style>
</head>
<body>
<h1>Terms and Conditions</h1>
<p>Last updated: August 25, 2026</p>
<h2>1. Application Usage</h2>
<p>By using the Kifi application, you agree to comply with these terms. Kifi is an enterprise-grade financial and project management tool.</p>
<h2>2. User Account Responsibilities</h2>
<p>You are responsible for maintaining the confidentiality of your account credentials. The accuracy of the information provided during account setup, including business details, is your responsibility.</p>
<h2>3. Financial Information Disclaimer</h2>
<p>Kifi provides personal and business accounting features. We do not provide financial, legal, or tax advice. You should consult a professional for your specific accounting and tax obligations.</p>
<h2>4. Prohibited Use</h2>
<p>You may not use Kifi for illegal activities, nor may you compromise the security or integrity of the platform.</p>
<h2>5. Limitation of Liability</h2>
<p>We provide the service "as is". We are not liable for direct, indirect, incidental, or consequential damages resulting from your use of the service or any data loss.</p>
<h2>6. Changes and Termination</h2>
<p>We reserve the right to suspend or terminate accounts that violate these terms. We may modify these terms at any time; continued use constitutes acceptance.</p>
</body>
</html>

View File

@@ -398,3 +398,25 @@ CREATE TABLE IF NOT EXISTS project_task_comments (
images TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ACCOUNT SETUP FLOW MIGRATIONS --
ALTER TABLE users ADD COLUMN IF NOT EXISTS username VARCHAR(255) UNIQUE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS name VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS mobile_number VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS setup_status VARCHAR(50) DEFAULT 'NOT_STARTED';
ALTER TABLE users ADD COLUMN IF NOT EXISTS setup_completed_at TIMESTAMP;
ALTER TABLE business_profiles ADD COLUMN IF NOT EXISTS nature_of_business VARCHAR(50);
ALTER TABLE business_profiles ADD COLUMN IF NOT EXISTS msme_number VARCHAR(255);
CREATE TABLE IF NOT EXISTS user_consents (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
consent_type VARCHAR(100) NOT NULL,
policy_version VARCHAR(50) NOT NULL,
accepted BOOLEAN DEFAULT TRUE,
accepted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address VARCHAR(100),
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

BIN
kifi-app/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@@ -102,6 +102,8 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
DEPENDENCIES:
- file_picker_darwin (from `.symlinks/plugins/file_picker_darwin/darwin`)
@@ -117,6 +119,7 @@ DEPENDENCIES:
- printing (from `.symlinks/plugins/printing/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
SPEC REPOS:
trunk:
@@ -163,6 +166,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS:
file_picker_darwin: 65c8ed1d70cc2ea0b81ae1840db93f8d9c051d19
@@ -193,6 +198,7 @@ SPEC CHECKSUMS:
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
PODFILE CHECKSUM: 05bcc0ae31139d41b9fa5949f7a59ee09c6fb20d

View File

@@ -15,8 +15,8 @@ class DioClient {
DioClient._internal()
: dio = Dio(BaseOptions(
baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2',
//baseUrl: 'http://192.168.0.104:8080/api/kifi-v2', // Local Mac IP for physical device testing
//baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2',
baseUrl: 'http://192.168.0.104:8080/api/kifi-v2', // Local Mac IP for physical device testing
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
)),

View File

@@ -35,4 +35,8 @@ class CryptoService {
}
bool get isInitialized => _publicKey != null;
void clearKey() {
_publicKey = null;
}
}

View File

@@ -0,0 +1,21 @@
class MaskingUtils {
static String maskMobile(String mobile) {
if (mobile.length < 4) return mobile;
return 'XXXXXX${mobile.substring(mobile.length - 4)}';
}
static String maskPan(String pan) {
if (pan.length < 4) return pan;
return '${pan.substring(0, 2)}XXXXXX${pan.substring(pan.length - 2)}';
}
static String maskGst(String gst) {
if (gst.length < 4) return gst;
return '${gst.substring(0, 2)}XXXXXXXXXX${gst.substring(gst.length - 3)}';
}
static String maskMsme(String msme) {
if (msme.length < 4) return msme;
return '${msme.substring(0, 4)}XXXX${msme.substring(msme.length - 4)}';
}
}

View File

@@ -28,19 +28,33 @@ class AuthRepository {
Future<Map<String, dynamic>> login(String email, String password) async {
try {
await _ensureCryptoReady();
final response = await dio.post('/auth/login', data: {
'email': _crypto.encrypt(email),
'password': _crypto.encrypt(password),
});
return response.data;
return await _attemptLogin(email, password);
} on DioException catch (e) {
if (e.response?.statusCode == 500 || e.response?.statusCode == 400 || e.response?.statusCode == 401) {
_crypto.clearKey();
try {
return await _attemptLogin(email, password);
} on DioException catch (e2) {
final data = e2.response?.data;
final errorMsg = data is Map ? data['error'] : data;
throw Exception(errorMsg ?? 'Invalid email or password');
}
}
final data = e.response?.data;
final errorMsg = data is Map ? data['error'] : data;
throw Exception(errorMsg ?? 'Invalid email or password');
}
}
Future<Map<String, dynamic>> _attemptLogin(String email, String password) async {
await _ensureCryptoReady();
final response = await dio.post('/auth/login', data: {
'email': _crypto.encrypt(email),
'password': _crypto.encrypt(password),
});
return response.data;
}
Future<Map<String, dynamic>> verifyOtp(String email, String otp) async {
try {
final response = await dio.post('/auth/verify-otp', data: {

View File

@@ -6,6 +6,8 @@ import '../providers/auth_provider.dart';
import 'otp_screen.dart';
import 'forgot_password_screen.dart';
import '../../dashboard/presentation/dashboard_screen.dart';
import '../../../../core/network/dio_client.dart';
import 'setup_wizard_screen.dart';
class AuthScreen extends ConsumerStatefulWidget {
const AuthScreen({super.key});
@@ -14,14 +16,15 @@ class AuthScreen extends ConsumerStatefulWidget {
ConsumerState<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends ConsumerState<AuthScreen> {
class _AuthScreenState extends ConsumerState<AuthScreen> with SingleTickerProviderStateMixin {
bool isLogin = true;
final emailController = TextEditingController();
final passwordController = TextEditingController();
final emailController = TextEditingController(text: 'maddy23285@gmail.com');
final passwordController = TextEditingController(text: 'Algorithm@123');
void toggleMode() {
void toggleMode(bool login) {
if (isLogin == login) return;
setState(() {
isLogin = !isLogin;
isLogin = login;
});
}
@@ -34,8 +37,18 @@ class _AuthScreenState extends ConsumerState<AuthScreen> {
if (isLogin) {
final success = await ref.read(authControllerProvider.notifier).login(email, password);
if (success && mounted) {
Navigator.pushReplacement(
context, MaterialPageRoute(builder: (_) => const DashboardScreen()));
try {
final dio = DioClient().dio;
final res = await dio.get('/account/setup/status');
final status = res.data['status'];
if (status == 'COMPLETED' && mounted) {
Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const DashboardScreen()));
} else if (mounted) {
Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const SetupWizardScreen()));
}
} catch(e) {
if (mounted) Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const SetupWizardScreen()));
}
}
} else {
final success = await ref.read(authControllerProvider.notifier).signup(email, password);
@@ -45,12 +58,34 @@ class _AuthScreenState extends ConsumerState<AuthScreen> {
}
}
InputDecoration _buildInputDecoration(String label, IconData icon) {
return InputDecoration(
labelText: label,
prefixIcon: Icon(icon, color: Colors.grey.shade500),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
);
}
@override
Widget build(BuildContext context) {
ref.listen<AsyncValue<void>>(authControllerProvider, (previous, next) {
if (next.hasError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${next.error}')),
SnackBar(content: Text('${next.error}')),
);
}
});
@@ -59,92 +94,149 @@ class _AuthScreenState extends ConsumerState<AuthScreen> {
final isLoading = state.isLoading;
return Scaffold(
backgroundColor: Colors.grey.shade50,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
isLogin ? 'Welcome Back!' : 'Create Account',
style: Theme.of(context).textTheme.displayLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
isLogin ? 'Login to continue to Kifi' : 'Sign up to manage your expenses',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Email Address',
prefixIcon: Icon(LucideIcons.mail, color: Colors.grey),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 16),
TextField(
controller: passwordController,
obscureText: true,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Password',
prefixIcon: Icon(LucideIcons.lock, color: Colors.grey),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
if (isLogin) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const ForgotPasswordScreen()));
},
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF6C63FF),
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 80, height: 80,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Text('Forgot Password?', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
child: Center(child: Image.asset('assets/logo.png', width: 50, height: 50, fit: BoxFit.contain)),
),
),
],
const SizedBox(height: 24),
ElevatedButton(
onPressed: isLoading ? null : submit,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(isLogin ? 'Login' : 'Sign Up', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 32),
Text(
'Welcome to Kifi',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.black87),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Manage your inventory and expenses securely.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey.shade600),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Expanded(
child: InkWell(
onTap: () => toggleMode(true),
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isLogin ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(12),
boxShadow: isLogin ? [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 4, offset: const Offset(0, 2))] : [],
),
child: Center(child: Text('Login', style: TextStyle(fontWeight: FontWeight.w600, color: isLogin ? Colors.black87 : Colors.grey.shade600))),
),
),
),
Expanded(
child: InkWell(
onTap: () => toggleMode(false),
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: !isLogin ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(12),
boxShadow: !isLogin ? [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 4, offset: const Offset(0, 2))] : [],
),
child: Center(child: Text('Sign Up', style: TextStyle(fontWeight: FontWeight.w600, color: !isLogin ? Colors.black87 : Colors.grey.shade600))),
),
),
),
],
),
),
const SizedBox(height: 32),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
style: const TextStyle(fontWeight: FontWeight.w500),
textInputAction: TextInputAction.next,
decoration: _buildInputDecoration(isLogin ? 'Email or Username' : 'Email Address', LucideIcons.mail),
),
const SizedBox(height: 16),
TextField(
controller: passwordController,
obscureText: true,
style: const TextStyle(fontWeight: FontWeight.w500),
textInputAction: TextInputAction.done,
onSubmitted: (_) => submit(),
decoration: _buildInputDecoration('Password', LucideIcons.lock),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: isLogin
? Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(top: 12),
child: TextButton(
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ForgotPasswordScreen())),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).primaryColor,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('Forgot Password?', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
),
),
)
: const SizedBox(height: 12),
),
],
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isLoading ? null : submit,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 2,
),
child: isLoading
? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(isLogin ? 'Log In' : 'Create Account', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 0.5)),
),
),
],
),
const SizedBox(height: 16),
TextButton(
onPressed: toggleMode,
child: Text(isLogin ? 'Don\'t have an account? Sign Up' : 'Already have an account? Login'),
),
],
),
),
),
),

View File

@@ -49,112 +49,129 @@ class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
}
}
InputDecoration _buildInputDecoration(String label, IconData icon) {
return InputDecoration(
labelText: label,
prefixIcon: Icon(icon, color: Colors.grey.shade500),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
backgroundColor: Colors.grey.shade50,
appBar: AppBar(
elevation: 0,
backgroundColor: Colors.transparent,
leading: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(LucideIcons.chevronLeft, size: 28),
color: Colors.black87,
),
),
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
style: IconButton.styleFrom(
backgroundColor: Colors.grey.shade100,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 40),
Center(
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6C63FF), Color(0xFF48C6EF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 80, height: 80,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
shape: BoxShape.circle,
),
borderRadius: BorderRadius.circular(24),
child: Center(child: Icon(LucideIcons.keyRound, size: 40, color: Theme.of(context).primaryColor)),
),
child: const Icon(LucideIcons.keyRound, color: Colors.white, size: 36),
),
),
const SizedBox(height: 32),
const Center(
child: Text(
'Forgot Password?',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 12),
Center(
child: Text(
'Enter your email address and we\'ll send you\na verification code to reset your password.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, color: Colors.grey.shade600, height: 1.5),
),
),
const SizedBox(height: 48),
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _sendOtp(),
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Email Address',
prefixIcon: Icon(LucideIcons.mail, color: Colors.grey.shade500),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.shade200),
const SizedBox(height: 32),
Text(
'Forgot Password?',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.black87),
textAlign: TextAlign.center,
),
child: Row(
children: [
Icon(LucideIcons.alertCircle, color: Colors.red.shade400, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(_errorMessage!, style: TextStyle(color: Colors.red.shade700, fontSize: 13))),
],
const SizedBox(height: 8),
Text(
'Enter your email address and we\'ll send you\na verification code to reset your password.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey.shade600, height: 1.5),
),
),
],
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _isLoading ? null : _sendOtp,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
const SizedBox(height: 40),
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _sendOtp(),
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: _buildInputDecoration('Email Address', LucideIcons.mail),
),
child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Send Reset Code', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: _errorMessage != null
? Padding(
padding: const EdgeInsets.only(top: 16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(LucideIcons.alertCircle, color: Colors.red.shade400, size: 20),
const SizedBox(width: 12),
Expanded(child: Text(_errorMessage!, style: TextStyle(color: Colors.red.shade700, fontSize: 14, fontWeight: FontWeight.w500))),
],
),
),
)
: const SizedBox.shrink(),
),
const SizedBox(height: 40),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _sendOtp,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 2,
),
child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Send Reset Code', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 0.5)),
),
),
],
),
],
),
),
),
),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../providers/auth_provider.dart';
import '../../dashboard/presentation/dashboard_screen.dart';
@@ -30,7 +31,7 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
ref.listen<AsyncValue<void>>(authControllerProvider, (previous, next) {
if (next.hasError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${next.error}')),
SnackBar(content: Text('${next.error}')),
);
}
});
@@ -38,58 +39,91 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
final state = ref.watch(authControllerProvider);
final isLoading = state.isLoading;
return Scaffold(
appBar: AppBar(backgroundColor: Colors.transparent, elevation: 0),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Verify Email',
style: Theme.of(context).textTheme.displayLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Enter the 6-digit OTP sent to ${widget.email}',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextField(
controller: otpController,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
style: const TextStyle(fontSize: 24, letterSpacing: 8, fontWeight: FontWeight.bold, color: Color(0xFF6C63FF)),
decoration: InputDecoration(
hintText: '000000',
hintStyle: TextStyle(letterSpacing: 8, color: Colors.grey.shade400),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
backgroundColor: Colors.grey.shade50,
appBar: AppBar(
elevation: 0,
backgroundColor: Colors.transparent,
leading: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(LucideIcons.chevronLeft, size: 28),
color: Colors.black87,
),
),
),
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 80, height: 80,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Center(child: Icon(LucideIcons.mailCheck, size: 40, color: Theme.of(context).primaryColor)),
),
const SizedBox(height: 32),
Text(
'Verify Email',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.black87),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Enter the 6-digit OTP sent to\n${widget.email}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey.shade600, height: 1.5),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextField(
controller: otpController,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
style: const TextStyle(fontSize: 24, letterSpacing: 16, fontWeight: FontWeight.bold),
decoration: InputDecoration(
hintText: '000000',
hintStyle: TextStyle(letterSpacing: 16, color: Colors.grey.shade300),
counterText: '',
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade300)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade300)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2)),
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isLoading ? null : verify,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 2,
),
child: isLoading
? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('Verify Account', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 0.5)),
),
),
],
),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: isLoading ? null : verify,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('Verify', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
),
),
),

View File

@@ -25,6 +25,40 @@ class ProfileScreen extends ConsumerStatefulWidget {
class _ProfileScreenState extends ConsumerState<ProfileScreen> {
bool _isExporting = false;
String? _profileType;
String _userName = 'Loading...';
String _userEmail = 'Loading...';
@override
void initState() {
super.initState();
_fetchProfileType();
}
Future<void> _fetchProfileType() async {
try {
final res = await DioClient().dio.get('/account/setup/status');
if (mounted) {
setState(() {
_profileType = res.data['profileType'];
_userName = res.data['name'] ?? 'Kifi User';
if (_userName.isEmpty) _userName = 'Kifi User';
_userEmail = res.data['email'] ?? '';
});
}
} catch (e) {
// ignore
}
}
String _getInitials(String name) {
if (name.isEmpty || name == 'Kifi User' || name == 'Loading...') return 'U';
final parts = name.trim().split(' ');
if (parts.length > 1 && parts[1].isNotEmpty) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
}
return name.substring(0, name.length >= 2 ? 2 : 1).toUpperCase();
}
Future<void> _logout(BuildContext context) async {
const storage = FlutterSecureStorage();
@@ -63,131 +97,211 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
title: const Text('Profile'),
title: Text('Profile', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 20),
CircleAvatar(
radius: 50,
backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.1),
child: Icon(LucideIcons.user, size: 50, color: Theme.of(context).colorScheme.primary),
),
const SizedBox(height: 24),
const Text('Kifi User', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('maddy23285@gmail.com', style: TextStyle(color: Colors.grey, fontSize: 16)),
const SizedBox(height: 48),
Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Column(
children: [
ListTile(
leading: const Icon(LucideIcons.downloadCloud),
title: const Text('Export Data to CSV'),
trailing: _isExporting
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(LucideIcons.chevronRight),
onTap: _isExporting ? null : _exportData,
),
const Divider(height: 1),
ListTile(
leading: const Icon(LucideIcons.moon),
title: const Text('Theme'),
trailing: SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(value: ThemeMode.light, icon: Icon(LucideIcons.sun)),
ButtonSegment(value: ThemeMode.system, icon: Icon(LucideIcons.monitor)),
ButtonSegment(value: ThemeMode.dark, icon: Icon(LucideIcons.moon)),
],
selected: {ref.watch(themeProvider)},
onSelectionChanged: (Set<ThemeMode> newSelection) {
ref.read(themeProvider.notifier).setTheme(newSelection.first);
},
showSelectedIcon: false,
style: ButtonStyle(visualDensity: VisualDensity.compact),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 20),
Center(
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Center(
child: Text(
_getInitials(_userName),
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
),
),
const Divider(height: 1),
Consumer(
builder: (context, ref, child) {
final isBusinessMode = ref.watch(businessModeProvider);
return Column(
children: [
SwitchListTile(
secondary: const Icon(LucideIcons.briefcase),
title: const Text('Business Mode'),
subtitle: const Text('Inventory and sales management'),
value: isBusinessMode,
onChanged: (val) {
ref.read(businessModeProvider.notifier).toggleMode();
},
),
if (isBusinessMode) ...[
const Divider(height: 1),
ListTile(
leading: const Icon(LucideIcons.settings),
title: const Text('Business Settings'),
subtitle: const Text('Tax, Modules, POS'),
trailing: const Icon(LucideIcons.chevronRight),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const BusinessSettingsScreen()));
},
),
],
const Divider(height: 1),
Consumer(
builder: (context, ref, child) {
final isProjectMode = ref.watch(projectModeProvider);
return SwitchListTile(
secondary: const Icon(LucideIcons.trello),
title: const Text('Project Management (Agency OS)'),
subtitle: const Text('Task boards and billable hours'),
value: isProjectMode,
onChanged: (val) {
ref.read(projectModeProvider.notifier).toggleMode();
},
);
},
)
],
);
}
),
const Divider(height: 1),
ListTile(
leading: const Icon(LucideIcons.helpCircle),
title: const Text('Help & Support'),
trailing: const Icon(LucideIcons.chevronRight),
onTap: () {},
),
const SizedBox(height: 24),
Text(
_userName,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.bodyLarge?.color),
textAlign: TextAlign.center,
),
if (_userEmail.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_userEmail,
style: TextStyle(color: isDark ? Colors.grey.shade400 : Colors.grey.shade600, fontSize: 16),
textAlign: TextAlign.center,
),
],
),
),
const Spacer(),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _logout(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.withOpacity(0.1),
foregroundColor: Colors.red,
padding: const EdgeInsets.symmetric(vertical: 16),
const SizedBox(height: 48),
Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(isDark ? 0.2 : 0.02), blurRadius: 10, offset: const Offset(0, 4)),
],
border: Border.all(color: Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.2)),
),
child: Column(
children: [
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.blue.shade50, borderRadius: BorderRadius.circular(10)),
child: Icon(LucideIcons.downloadCloud, color: Colors.blue.shade600, size: 22),
),
title: const Text('Export Data to CSV', style: TextStyle(fontWeight: FontWeight.w600)),
trailing: _isExporting
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(LucideIcons.chevronRight, size: 20),
onTap: _isExporting ? null : _exportData,
),
Divider(height: 1, indent: 64, color: Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.05)),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.purple.shade50, borderRadius: BorderRadius.circular(10)),
child: Icon(LucideIcons.moon, color: Colors.purple.shade600, size: 22),
),
title: const Text('Theme', style: TextStyle(fontWeight: FontWeight.w600)),
trailing: SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(value: ThemeMode.light, icon: Icon(LucideIcons.sun, size: 18)),
ButtonSegment(value: ThemeMode.system, icon: Icon(LucideIcons.monitor, size: 18)),
ButtonSegment(value: ThemeMode.dark, icon: Icon(LucideIcons.moon, size: 18)),
],
selected: {ref.watch(themeProvider)},
onSelectionChanged: (Set<ThemeMode> newSelection) {
ref.read(themeProvider.notifier).setTheme(newSelection.first);
},
showSelectedIcon: false,
style: ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
),
Divider(height: 1, indent: 64, color: Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.05)),
Consumer(
builder: (context, ref, child) {
final isBusinessMode = ref.watch(businessModeProvider);
return Column(
children: [
if (_profileType == 'BUSINESS') ...[
SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
secondary: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.orange.shade50, borderRadius: BorderRadius.circular(10)),
child: Icon(LucideIcons.briefcase, color: Colors.orange.shade600, size: 22),
),
title: const Text('Business Mode', style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text('Inventory and sales', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
value: isBusinessMode,
activeColor: Theme.of(context).primaryColor,
onChanged: (val) {
ref.read(businessModeProvider.notifier).toggleMode();
},
),
if (isBusinessMode) ...[
Divider(height: 1, indent: 64, color: Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.05)),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(10)),
child: Icon(LucideIcons.settings, color: Colors.grey.shade700, size: 22),
),
title: const Text('Business Settings', style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text('Tax, Modules, POS', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
trailing: const Icon(LucideIcons.chevronRight, size: 20),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const BusinessSettingsScreen()));
},
),
],
Divider(height: 1, indent: 64, color: Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.05)),
Consumer(
builder: (context, ref, child) {
final isProjectMode = ref.watch(projectModeProvider);
return SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
secondary: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.teal.shade50, borderRadius: BorderRadius.circular(10)),
child: Icon(LucideIcons.trello, color: Colors.teal.shade600, size: 22),
),
title: const Text('Project Management', style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text('Task boards and tracking', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
value: isProjectMode,
activeColor: Theme.of(context).primaryColor,
onChanged: (val) {
ref.read(projectModeProvider.notifier).toggleMode();
},
);
},
),
Divider(height: 1, indent: 64, color: Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.05)),
],
],
);
}
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.green.shade50, borderRadius: BorderRadius.circular(10)),
child: Icon(LucideIcons.helpCircle, color: Colors.green.shade600, size: 22),
),
title: const Text('Help & Support', style: TextStyle(fontWeight: FontWeight.w600)),
trailing: const Icon(LucideIcons.chevronRight, size: 20),
onTap: () {},
),
],
),
),
icon: const Icon(LucideIcons.logOut),
label: const Text('Log Out', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
const SizedBox(height: 48),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _logout(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.withOpacity(0.08),
foregroundColor: Colors.red.shade700,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
icon: const Icon(LucideIcons.logOut, size: 22),
label: const Text('Log Out', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, letterSpacing: 0.5)),
),
),
const SizedBox(height: 20),
],
),
const SizedBox(height: 20),
],
),
),
),
),

View File

@@ -74,161 +74,180 @@ class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> {
}
}
InputDecoration _buildInputDecoration(String hint, {IconData? prefixIcon, Widget? suffixIcon}) {
return InputDecoration(
hintText: hint,
prefixIcon: prefixIcon != null ? Icon(prefixIcon, color: Colors.grey.shade500) : null,
suffixIcon: suffixIcon,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
backgroundColor: Colors.grey.shade50,
appBar: AppBar(
elevation: 0,
backgroundColor: Colors.transparent,
leading: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(LucideIcons.chevronLeft, size: 28),
color: Colors.black87,
),
),
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
style: IconButton.styleFrom(
backgroundColor: Colors.grey.shade100,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 32),
Center(
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6C63FF), Color(0xFF48C6EF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 80, height: 80,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
shape: BoxShape.circle,
),
borderRadius: BorderRadius.circular(24),
child: Center(child: Icon(LucideIcons.shieldCheck, size: 40, color: Theme.of(context).primaryColor)),
),
child: const Icon(LucideIcons.shieldCheck, color: Colors.white, size: 36),
),
),
const SizedBox(height: 28),
const Center(
child: Text(
'Reset Password',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 12),
Center(
child: Text(
'We\'ve sent a 6-digit code to\n${widget.email}',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, color: Colors.grey.shade600, height: 1.5),
),
),
const SizedBox(height: 40),
const SizedBox(height: 32),
Text(
'Reset Password',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.black87),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'We\'ve sent a 6-digit code to\n${widget.email}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey.shade600, height: 1.5),
),
const SizedBox(height: 40),
// OTP Field
TextField(
controller: otpController,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
maxLength: 6,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 20, letterSpacing: 8),
textAlign: TextAlign.center,
decoration: InputDecoration(
hintText: '000000',
hintStyle: TextStyle(color: Colors.grey.shade300, letterSpacing: 8),
counterText: '',
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 20),
// OTP Field
TextField(
controller: otpController,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
maxLength: 6,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 24, letterSpacing: 16),
textAlign: TextAlign.center,
decoration: InputDecoration(
hintText: '000000',
hintStyle: TextStyle(color: Colors.grey.shade300, letterSpacing: 16),
counterText: '',
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade300)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade300)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2)),
),
),
const SizedBox(height: 24),
// New Password
TextField(
controller: newPasswordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.next,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'New Password',
prefixIcon: Icon(LucideIcons.lock, color: Colors.grey.shade500),
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? LucideIcons.eyeOff : LucideIcons.eye, color: Colors.grey.shade500),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
// New Password
TextField(
controller: newPasswordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.next,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: _buildInputDecoration(
'New Password',
prefixIcon: LucideIcons.lock,
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? LucideIcons.eyeOff : LucideIcons.eye, color: Colors.grey.shade500),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
),
),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 16),
const SizedBox(height: 16),
// Confirm Password
TextField(
controller: confirmPasswordController,
obscureText: _obscureConfirm,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _resetPassword(),
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Confirm Password',
prefixIcon: Icon(LucideIcons.lock, color: Colors.grey.shade500),
suffixIcon: IconButton(
icon: Icon(_obscureConfirm ? LucideIcons.eyeOff : LucideIcons.eye, color: Colors.grey.shade500),
onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm),
// Confirm Password
TextField(
controller: confirmPasswordController,
obscureText: _obscureConfirm,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _resetPassword(),
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: _buildInputDecoration(
'Confirm Password',
prefixIcon: LucideIcons.lock,
suffixIcon: IconButton(
icon: Icon(_obscureConfirm ? LucideIcons.eyeOff : LucideIcons.eye, color: Colors.grey.shade500),
onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm),
),
),
),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
if (_errorMessage != null) ...[
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.shade200),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: _errorMessage != null
? Padding(
padding: const EdgeInsets.only(top: 16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(LucideIcons.alertCircle, color: Colors.red.shade400, size: 20),
const SizedBox(width: 12),
Expanded(child: Text(_errorMessage!, style: TextStyle(color: Colors.red.shade700, fontSize: 14, fontWeight: FontWeight.w500))),
],
),
),
)
: const SizedBox.shrink(),
),
child: Row(
children: [
Icon(LucideIcons.alertCircle, color: Colors.red.shade400, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(_errorMessage!, style: TextStyle(color: Colors.red.shade700, fontSize: 13))),
],
),
),
],
const SizedBox(height: 32),
const SizedBox(height: 40),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _isLoading ? null : _resetPassword,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _resetPassword,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 2,
),
child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Reset Password', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 0.5)),
),
),
child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Reset Password', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
],
),
),
),
),

View File

@@ -0,0 +1,501 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl_phone_field/intl_phone_field.dart';
import '../../../../core/network/dio_client.dart';
import '../../dashboard/presentation/dashboard_screen.dart';
import 'package:url_launcher/url_launcher.dart';
class SetupWizardScreen extends StatefulWidget {
const SetupWizardScreen({super.key});
@override
State<SetupWizardScreen> createState() => _SetupWizardScreenState();
}
class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTickerProviderStateMixin {
bool _isLoading = false;
int _currentStep = 0;
String _accountType = 'INDIVIDUAL';
// Step 1: Personal Info
final _nameController = TextEditingController();
final _usernameController = TextEditingController();
String _mobileNumber = '';
bool _isUsernameAvailable = false;
bool _checkingUsername = false;
// Step 2: Business Info (if BUSINESS)
String? _natureOfBusiness;
final _businessNameController = TextEditingController();
final _addressController = TextEditingController();
final _emailController = TextEditingController();
final _gstController = TextEditingController();
final _panController = TextEditingController();
final _msmeController = TextEditingController();
// Step 3: Consent
bool _termsAccepted = false;
bool _privacyAccepted = false;
int get _totalSteps => _accountType == 'BUSINESS' ? 4 : 3;
Future<void> _checkUsername(String username) async {
if (username.length < 3) return;
setState(() => _checkingUsername = true);
try {
final res = await DioClient().dio.get('/account/username/availability', queryParameters: {'username': username});
if (mounted) {
setState(() {
_isUsernameAvailable = res.data['available'] == true;
_checkingUsername = false;
});
}
} catch (e) {
if (mounted) setState(() => _checkingUsername = false);
}
}
Future<void> _submitSetup() async {
if (!_termsAccepted || !_privacyAccepted) return;
setState(() => _isLoading = true);
try {
final data = {
'accountType': _accountType,
'name': _nameController.text,
'username': _usernameController.text,
'mobileNumber': _mobileNumber,
'termsAccepted': _termsAccepted,
'privacyAccepted': _privacyAccepted,
};
if (_accountType == 'BUSINESS') {
data.addAll({
'businessName': _businessNameController.text.isNotEmpty ? _businessNameController.text : _nameController.text,
'natureOfBusiness': _natureOfBusiness!,
'address': _addressController.text,
'emailId': _emailController.text,
'gstin': _gstController.text,
'panNumber': _panController.text,
'msmeNumber': _msmeController.text,
});
}
await DioClient().dio.post('/account/setup', data: data);
if (mounted) {
Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const DashboardScreen()));
}
} catch (e) {
if (mounted) setState(() => _isLoading = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Setup failed: $e')));
}
}
}
Future<void> _launchUrl(String path) async {
final url = Uri.parse('http://192.168.0.104:8080/public/legal/$path');
if (await canLaunchUrl(url)) {
await launchUrl(url);
}
}
void _nextStep() {
if (_currentStep == 1) {
if (_nameController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please enter your Full Name')));
return;
}
if (_usernameController.text.trim().length < 3) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Username must be at least 3 characters')));
return;
}
if (!_isUsernameAvailable) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Username is unavailable or invalid')));
return;
}
}
if (_accountType == 'BUSINESS' && _currentStep == 2) {
if (_natureOfBusiness == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select Nature of Business')));
return;
}
}
if (_currentStep < _totalSteps - 1) {
setState(() => _currentStep++);
} else {
_submitSetup();
}
}
void _prevStep() {
if (_currentStep > 0) {
setState(() => _currentStep--);
}
}
InputDecoration _buildInputDecoration(String label) {
return InputDecoration(
labelText: label,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
);
}
Widget _buildProgressIndicator() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Step ${_currentStep + 1} of $_totalSteps',
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold, fontSize: 14),
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: (_currentStep + 1) / _totalSteps,
minHeight: 6,
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
),
),
],
),
);
}
Widget _buildStep0AccountType() {
return Column(
key: const ValueKey(0),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Welcome to Kifi!', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('How will you be using the application?', style: TextStyle(fontSize: 16, color: Colors.grey)),
const SizedBox(height: 32),
_buildAccountTypeCard('INDIVIDUAL', 'Individual', 'For personal use and expenses', LucideIcons.user),
const SizedBox(height: 16),
_buildAccountTypeCard('BUSINESS', 'Business', 'For managing business and inventory', LucideIcons.building2),
],
);
}
Widget _buildAccountTypeCard(String type, String title, String subtitle, IconData icon) {
final isSelected = _accountType == type;
return InkWell(
onTap: () => setState(() => _accountType = type),
borderRadius: BorderRadius.circular(16),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isSelected ? Theme.of(context).primaryColor.withOpacity(0.08) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected ? Theme.of(context).primaryColor : Colors.grey.shade300,
width: isSelected ? 2 : 1,
),
boxShadow: isSelected ? [] : [
BoxShadow(color: Colors.black.withOpacity(0.03), blurRadius: 10, offset: const Offset(0, 4)),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSelected ? Theme.of(context).primaryColor : Colors.grey.shade100,
shape: BoxShape.circle,
),
child: Icon(icon, color: isSelected ? Colors.white : Colors.grey[600], size: 28),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(subtitle, style: TextStyle(color: Colors.grey[600], fontSize: 14)),
],
),
),
Icon(
isSelected ? LucideIcons.checkCircle2 : LucideIcons.circle,
color: isSelected ? Theme.of(context).primaryColor : Colors.grey[300],
size: 28,
),
],
),
),
);
}
Widget _buildStep1Personal() {
return Column(
key: const ValueKey(1),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Personal Details', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Tell us a bit about yourself.', style: TextStyle(fontSize: 16, color: Colors.grey)),
const SizedBox(height: 32),
TextFormField(
controller: _nameController,
decoration: _buildInputDecoration('Full Name *'),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 20),
TextFormField(
controller: _usernameController,
decoration: _buildInputDecoration('Username *').copyWith(
suffixIcon: SizedBox(
width: 48, height: 48,
child: Center(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _checkingUsername
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Icon(
_isUsernameAvailable ? LucideIcons.checkCircle2 : LucideIcons.xCircle,
key: ValueKey(_isUsernameAvailable),
color: _usernameController.text.isEmpty ? Colors.transparent : (_isUsernameAvailable ? Colors.green : Colors.red),
),
),
),
),
),
onChanged: _checkUsername,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 20),
IntlPhoneField(
decoration: _buildInputDecoration('Mobile Number'),
initialCountryCode: 'IN',
onChanged: (phone) => _mobileNumber = phone.completeNumber,
),
],
);
}
Widget _buildStep2Business() {
return Column(
key: const ValueKey(2),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Business Profile', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Set up your company details for invoicing and tracking.', style: TextStyle(fontSize: 16, color: Colors.grey)),
const SizedBox(height: 32),
DropdownButtonFormField<String>(
decoration: _buildInputDecoration('Nature of Business *'),
value: _natureOfBusiness,
items: const [
DropdownMenuItem(value: 'JEWELLERY', child: Text('Jewellery')),
DropdownMenuItem(value: 'PROJECT_MANAGEMENT', child: Text('Project Management')),
DropdownMenuItem(value: 'INVENTORY_MANAGEMENT', child: Text('Inventory Management')),
],
onChanged: (val) => setState(() => _natureOfBusiness = val),
),
const SizedBox(height: 20),
TextFormField(
controller: _businessNameController,
decoration: _buildInputDecoration('Business Name (Optional)'),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 20),
TextFormField(
controller: _addressController,
decoration: _buildInputDecoration('Business Address (Optional)'),
maxLines: 3,
textInputAction: TextInputAction.newline,
),
const SizedBox(height: 20),
TextFormField(
controller: _emailController,
decoration: _buildInputDecoration('Official Email (Optional)'),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 20),
TextFormField(
controller: _gstController,
decoration: _buildInputDecoration('GST Number (Optional)'),
textCapitalization: TextCapitalization.characters,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 20),
TextFormField(
controller: _panController,
decoration: _buildInputDecoration('PAN (Optional)'),
textCapitalization: TextCapitalization.characters,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 20),
TextFormField(
controller: _msmeController,
decoration: _buildInputDecoration('MSME/Udyam Number (Optional)'),
textCapitalization: TextCapitalization.characters,
textInputAction: TextInputAction.done,
),
],
);
}
Widget _buildStep3Consent() {
return Column(
key: const ValueKey(3),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Final Step', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Please review and accept our policies to continue.', style: TextStyle(fontSize: 16, color: Colors.grey)),
const SizedBox(height: 32),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade300),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.02), blurRadius: 10, offset: const Offset(0, 4)),
],
),
child: Column(
children: [
CheckboxListTile(
title: InkWell(
onTap: () => _launchUrl('terms'),
child: const Text('I accept the Terms & Conditions', style: TextStyle(color: Colors.blue, decoration: TextDecoration.underline, fontSize: 15, fontWeight: FontWeight.w500)),
),
value: _termsAccepted,
controlAffinity: ListTileControlAffinity.leading,
activeColor: Theme.of(context).primaryColor,
onChanged: (val) => setState(() => _termsAccepted = val ?? false),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
Divider(height: 1, indent: 56, color: Colors.grey.shade200),
CheckboxListTile(
title: InkWell(
onTap: () => _launchUrl('privacy'),
child: const Text('I accept the Privacy Policy', style: TextStyle(color: Colors.blue, decoration: TextDecoration.underline, fontSize: 15, fontWeight: FontWeight.w500)),
),
value: _privacyAccepted,
controlAffinity: ListTileControlAffinity.leading,
activeColor: Theme.of(context).primaryColor,
onChanged: (val) => setState(() => _privacyAccepted = val ?? false),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
],
),
),
],
);
}
Widget _buildCurrentStep() {
if (_currentStep == 0) return _buildStep0AccountType();
if (_currentStep == 1) return _buildStep1Personal();
if (_accountType == 'BUSINESS' && _currentStep == 2) return _buildStep2Business();
return _buildStep3Consent(); // Step 2 (Individual) or Step 3 (Business)
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey.shade50,
appBar: AppBar(
elevation: 0,
backgroundColor: Colors.transparent,
leading: _currentStep > 0
? IconButton(icon: const Icon(LucideIcons.chevronLeft), onPressed: _prevStep)
: null,
),
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildProgressIndicator(),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.05, 0),
end: Offset.zero,
).animate(animation),
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
child: SingleChildScrollView(
key: ValueKey(_currentStep),
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: _buildCurrentStep(),
),
),
),
),
),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 24.0),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _nextStep,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 20),
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: _isLoading
? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(
_currentStep == _totalSteps - 1 ? 'Complete Setup' : 'Continue',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 0.5),
),
),
),
),
),
),
],
),
),
);
}
}

View File

@@ -24,6 +24,7 @@ class _CategorySpendingChartState extends State<CategorySpendingChart> {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final Map<int, double> categoryTotals = {};
for (var t in widget.transactions) {
bool matches = false;
@@ -74,9 +75,9 @@ class _CategorySpendingChartState extends State<CategorySpendingChart> {
? Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white,
color: Theme.of(context).cardColor,
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 4)],
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: isDark ? 0.3 : 0.1), blurRadius: 4)],
),
child: Icon(Icons.touch_app, size: 16, color: colors[i % colors.length]),
)
@@ -88,21 +89,21 @@ class _CategorySpendingChartState extends State<CategorySpendingChart> {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(32), side: BorderSide(color: Colors.grey.shade200)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(32), side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.2))),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(32),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.white, Colors.grey.shade50],
colors: isDark ? [Theme.of(context).cardColor, Theme.of(context).cardColor.withOpacity(0.8)] : [Colors.white, Colors.grey.shade50],
),
),
padding: const EdgeInsets.all(28.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Category Breakdown', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800, letterSpacing: -0.5)),
Text('Category Breakdown', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800, letterSpacing: -0.5, color: Theme.of(context).textTheme.bodyLarge?.color)),
const SizedBox(height: 32),
SizedBox(
@@ -134,7 +135,7 @@ class _CategorySpendingChartState extends State<CategorySpendingChart> {
mainAxisSize: MainAxisSize.min,
children: [
Text('Total', style: TextStyle(fontSize: 14, color: Colors.grey.shade500, fontWeight: FontWeight.w600)),
Text('Rs. ${totalAmount.toStringAsFixed(0)}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900)),
Text('Rs. ${totalAmount.toStringAsFixed(0)}', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Theme.of(context).textTheme.bodyLarge?.color)),
],
),
],
@@ -142,7 +143,7 @@ class _CategorySpendingChartState extends State<CategorySpendingChart> {
),
const SizedBox(height: 40),
const Text('Details', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Colors.black87)),
Text('Details', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Theme.of(context).textTheme.bodyLarge?.color)),
const SizedBox(height: 16),
// Modern List for Legends
@@ -162,10 +163,10 @@ class _CategorySpendingChartState extends State<CategorySpendingChart> {
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
decoration: BoxDecoration(
color: isTouched ? colors[i % colors.length].withValues(alpha: 0.05) : Colors.white,
color: isTouched ? colors[i % colors.length].withValues(alpha: 0.05) : (isDark ? Theme.of(context).scaffoldBackgroundColor : Colors.white),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isTouched ? colors[i % colors.length].withValues(alpha: 0.3) : Colors.grey.shade100,
color: isTouched ? colors[i % colors.length].withValues(alpha: 0.3) : Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.05),
width: isTouched ? 1.5 : 1.0,
),
boxShadow: [
@@ -195,7 +196,7 @@ class _CategorySpendingChartState extends State<CategorySpendingChart> {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(categoryName, style: TextStyle(fontWeight: FontWeight.w700, fontSize: 15, color: Colors.grey.shade800)),
Text(categoryName, style: TextStyle(fontWeight: FontWeight.w700, fontSize: 15, color: Theme.of(context).textTheme.bodyLarge?.color)),
const SizedBox(height: 4),
Text('Rs. ${amount.toStringAsFixed(0)}', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13, color: Colors.grey.shade500)),
],

View File

@@ -39,9 +39,10 @@ class _StatisticsTabState extends State<StatisticsTab> {
String _selectedNature = 'EXPENSE';
Widget _buildTotalCard(String title, double amount, Color color, IconData icon) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: Colors.grey.shade200)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.2))),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
@@ -171,7 +172,7 @@ class _StatisticsTabState extends State<StatisticsTab> {
if (widget.transactions.isEmpty)
Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24), side: BorderSide(color: Colors.grey.shade200)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24), side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(Theme.of(context).brightness == Brightness.dark ? 0.1 : 0.2))),
child: const Padding(
padding: EdgeInsets.all(32.0),
child: Center(child: Text('No transactions in this period.', style: TextStyle(color: Colors.grey))),

View File

@@ -49,6 +49,7 @@ class _SwipeableAccountCardState extends State<SwipeableAccountCard> {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
if (widget.items.isEmpty) {
return const SizedBox.shrink();
}
@@ -73,11 +74,11 @@ class _SwipeableAccountCardState extends State<SwipeableAccountCard> {
margin: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade200),
border: Border.all(color: Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.2)),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.02), blurRadius: 8, offset: const Offset(0, 2))
BoxShadow(color: Colors.black.withOpacity(isDark ? 0.2 : 0.02), blurRadius: 8, offset: const Offset(0, 2))
],
),
child: Row(
@@ -120,7 +121,7 @@ class _SwipeableAccountCardState extends State<SwipeableAccountCard> {
),
Text(
'Rs. ${item.amount.toStringAsFixed(0)}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.black87),
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Theme.of(context).textTheme.bodyLarge?.color),
),
],
),

View File

@@ -11,6 +11,7 @@ class UpcomingDuesWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final walletsState = ref.watch(walletProvider);
if (!walletsState.hasValue || walletsState.value == null) {
@@ -126,11 +127,11 @@ class UpcomingDuesWidget extends ConsumerWidget {
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isUrgent ? Colors.red.shade50 : Colors.white,
color: isUrgent ? (isDark ? Colors.red.withOpacity(0.2) : Colors.red.shade50) : Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isUrgent ? Colors.red.shade200 : Colors.grey.shade200),
border: Border.all(color: isUrgent ? (isDark ? Colors.red.withOpacity(0.5) : Colors.red.shade200) : Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.2)),
boxShadow: [
if (!isUrgent) BoxShadow(color: Colors.black.withOpacity(0.02), blurRadius: 8, offset: const Offset(0, 2))
if (!isUrgent) BoxShadow(color: Colors.black.withOpacity(isDark ? 0.2 : 0.02), blurRadius: 8, offset: const Offset(0, 2))
],
),
child: Row(
@@ -153,7 +154,7 @@ class UpcomingDuesWidget extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(w.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
Text(w.name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: Theme.of(context).textTheme.bodyLarge?.color)),
const SizedBox(height: 4),
Text(
'$label${DateFormat('MMM dd').format(dueDate)}',
@@ -171,7 +172,7 @@ class UpcomingDuesWidget extends ConsumerWidget {
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: isUrgent ? Colors.red.shade700 : Colors.black87
color: isUrgent ? (isDark ? Colors.redAccent : Colors.red.shade700) : Theme.of(context).textTheme.bodyLarge?.color
),
),
],

View File

@@ -163,7 +163,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(28),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))],
),
@@ -180,7 +180,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
decoration: InputDecoration(
labelText: 'Item Name',
filled: true,
fillColor: Colors.grey.shade100,
fillColor: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade900 : Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
)
@@ -194,9 +194,9 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
decoration: InputDecoration(
labelText: 'Amount',
prefixText: 'Rs. ',
prefixStyle: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
prefixStyle: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color, fontWeight: FontWeight.w500, fontSize: 16),
filled: true,
fillColor: Colors.grey.shade100,
fillColor: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade900 : Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
)
@@ -260,7 +260,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(28),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))],
),
@@ -287,7 +287,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
decoration: InputDecoration(
hintText: 'Account Name (e.g. Household)',
filled: true,
fillColor: Colors.grey.shade100,
fillColor: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade900 : Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
@@ -299,7 +299,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
decoration: InputDecoration(
labelText: 'Account Nature',
filled: true,
fillColor: Colors.grey.shade100,
fillColor: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade900 : Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
@@ -482,7 +482,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(28),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))],
),
@@ -500,7 +500,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
decoration: InputDecoration(
hintText: 'Name',
filled: true,
fillColor: Colors.grey.shade100,
fillColor: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade900 : Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
@@ -749,7 +749,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
hintText: '0.00',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixText: 'Rs. ',
prefixStyle: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black87),
prefixStyle: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.bodyLarge?.color),
filled: true,
fillColor: const Color(0xFF6C63FF).withValues(alpha: 0.05),
contentPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 24),
@@ -766,7 +766,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
hintText: 'Description (Optional)',
prefixIcon: const Icon(LucideIcons.alignLeft, color: Colors.grey),
filled: true,
fillColor: Colors.grey.shade100,
fillColor: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade900 : Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),

View File

@@ -15,6 +15,7 @@ import mobile_scanner
import printing
import share_plus
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
@@ -27,4 +28,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View File

@@ -672,6 +672,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.3"
intl_phone_field:
dependency: "direct main"
description:
name: intl_phone_field
sha256: "73819d3dfcb68d2c85663606f6842597c3ddf6688ac777f051b17814fe767bbf"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
io:
dependency: transitive
description:
@@ -1269,6 +1277,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
@@ -1277,6 +1309,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:

View File

@@ -58,6 +58,8 @@ dependencies:
pdf: ^3.12.0
printing: ^5.14.3
file_picker: ^12.0.0
intl_phone_field: ^3.2.0
url_launcher: ^6.3.2
dev_dependencies:
flutter_test:
@@ -83,9 +85,9 @@ flutter:
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/icon.png
- assets/logo.png
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images

View File

@@ -1,32 +0,0 @@
# Kifi Agency OS: Project Management Integration Plan
## The Goal
Transform Kifi into an all-in-one "Agency Operating System" by deeply integrating project management. This guarantees massive Daily Active Usage (DAU) and locks in teams by managing both their daily workflows and their finances in a single ecosystem.
## 1. Modular Architecture (Preserving Existing Flows)
- **The "Modules" Strategy:** Project Management must be an optional toggle in `Settings -> Modules`.
- **UI Impact:** If toggled ON, a new **"Projects"** tab appears in the main navigation. If OFF, the UI remains exactly as it is today (perfect for retail/manufacturing users).
- **Core Entity:** `Project` (linked to `Customer/Client`).
## 2. Financially-Aware Task Boards
Unlike Jira, Kifi connects tasks directly to money.
- **Kanban View:** Standard columns (To-Do, In Progress, Review, Done).
- **Billable Tasks:** A checkbox on every task: `[x] Billable ($50/hr)`.
- **1-Click Invoicing:** When a billable task is moved to "Done", a prompt appears: *"Generate invoice for this task?"* or *"Add to next client invoice?"*
- **Budget Tracking:** Project boards display a real-time progress bar of the client's budget vs. hours logged.
## 3. Multi-User Collaboration & Monetization
- **Team Roles:** Invite designers, developers, and clients to specific project boards.
- **Attachments & Comments:** Upload specs, designs, and have threaded conversations on tasks.
- **Monetization Engine:** The core app remains free/freemium, but inviting team members to collaborate on tasks costs **$5/user/month** (Workspace Pricing Model).
## 4. Minimum Viable Product (MVP) Rollout
**Phase 1:**
- `projects` table (id, name, customer_id, budget, status).
- `tasks` table (id, project_id, title, description, assignee_id, status, is_billable, hourly_rate, hours_logged).
- Basic Flutter UI: A Kanban board screen and a simple task creation modal.
**Phase 2:**
- Automated Invoice Generation from "Done" billable tasks.
- Team member invitations and role-based permissions for boards.
- Client Portal access (clients can log in to view task progress and pay invoices).

View File

@@ -23,9 +23,17 @@ Kifi is a financial and inventory management application designed to handle stri
- Product pricing can follow auto-calculated rules.
- Images are uploaded as Multipart form data, converted to Base64 in the backend, and sent to MinIO.
### 3. Vendor & Purchase Orders (Current Focus)
### 3. Vendor & Purchase Orders
- Active development on Vendor Management, Purchase Orders, and Purchase Payments.
### 4. Project Management (Tasks)
- Supports full Project creation, task assignment, and billing rates.
- **Task Comments & Attachments**: Task comments support both legacy Base64 attachments (stored in PostgreSQL) and newer MinIO-backed attachments. Native downloading and sharing of non-image files (PDFs, Docs) is implemented via `file_picker` and `share_plus`. Images utilize a full-screen zoomable gallery.
### 5. UI Standardization
- **Design System**: The application strictly adheres to a uniform, enterprise-grade design system across all list screens (Projects, Invoices, Vendors, Task Board).
- **Standard Themes**: `Colors.grey[100]` is the standard background color, and search fields use white containers with rounded corners and no borders. The `CustomersScreen` serves as the UI source of truth.
## Important Technical Rules & Conventions
1. **R2DBC Limitations**: Because R2DBC is fully reactive, it does not automatically fetch relations (no lazy loading like Hibernate). Transient relational fields (e.g. `@Transient List<ProductImage> images` in `Product`) MUST be manually populated in the Service layers using `Mono.zip` or `flatMap` before returning to the controller.
2. **API Routing (kifi vs kifi-v2)**: The application is migrating to a new reactive backend. New endpoints are mapped under `/api/kifi-v2/` (e.g., `/api/kifi-v2/inventory/products`), while some legacy mobile integrations might still point to `/api/kifi/` (e.g., transactions). Pay close attention to Base URL configurations in `DioClient` vs hardcoded URLs in UI widgets.

View File

@@ -1,28 +1,89 @@
# Kifi Project Handoff
# Kifi Project Handoff Document
## Current Status
- Transitioning to Vendor Management and Purchase Orders functionality. The user is currently exploring `purchase_order_details_screen.dart` and related vendor screens.
- **System Note**: The backend Spring Boot server task was previously stopped due to a server restart. It must be restarted to test APIs locally (`./mvnw spring-boot:run` in `kifi-api`).
This document provides an overview of the Kifi project, detailing the architecture, recent implementations, current state, and instructions for running the application.
## Recently Completed
- **Payables Account Types**: Successfully updated schema and UI to support advanced payables (`sub_nature`, `credit_limit`, `fixed_amount`, `payment_cycle`, `cycle_date`). Handled complex logic for managing recurring CC bills, EMIs, and ODs.
- **Dashboard Enhancements**: Implemented `UpcomingDuesWidget` which accurately calculates and warns about urgent upcoming dues based on negative wallet balances and recurring cycle dates.
## 1. Project Overview & Architecture
## Outstanding Issues & Tasks
1. **Product Image Preview Bug (High Priority)**:
- *Symptom*: Images uploaded to products are saved successfully in MinIO and DB, but the thumbnail/preview in the Flutter `product_list_screen.dart` is not rendering correctly (showing the placeholder instead). Tapping into edit mode also reportedly fails to show existing images.
- *Context*: The user noted that the *Transaction* image preview works fine.
- *Investigation Notes*:
- Transactions use `NetworkImage` hardcoded to `/api/kifi/transactions/...` (potentially hitting the old backend), while Products use `Image.network` pointing to `/api/kifi-v2/inventory/products/...` (hitting the new WebFlux backend).
- Need to verify if `ProductService` is correctly mapping and returning `imageIds` in the JSON response to the Flutter app.
- Need to update `add_product_screen.dart` to fetch and render existing images when editing a product, similar to how `add_transaction_screen.dart` handles it.
2. **Vendor / Purchase Order Module**: Active development on Vendor Purchase Orders (frontend screens currently open by user).
3. **Other Backlog**:
- Notification scheduling customization.
- Bulk Operations (multi-select/dropdown for bulk invite/delete).
- Verify Release Build (iOS code signing verification).
Kifi is an enterprise-grade project management and CRM application consisting of a mobile frontend and a reactive backend.
## Next Steps for the Next Agent
1. **Restart Backend**: Restart the Spring Boot server (`kifi-api`) if you need to perform local API testing.
2. **Resolve Product Image Bug**: Finalize the investigation into why product images aren't displaying in `product_list_screen.dart` and `add_product_screen.dart` by comparing with the working `Transaction` image logic.
3. **Assist with Vendor Features**: Provide support on `purchase_order_details_screen.dart` and the broader vendor management module as requested by the user.
### **Backend (`kifi-api`)**
- **Framework:** Spring Boot 3 with WebFlux (Reactive Stack)
- **Language:** Java 21
- **Database:** PostgreSQL accessed via Spring Data R2DBC
- **Caching & Sessions:** Redis (Reactive)
- **Security:** Spring Security with JJWT for token-based authentication
- **Storage:** Minio for S3-compatible object storage
- **Architecture Pattern:** Standard N-Tier (Controller → Service → Repository → Entity/DTO)
### **Frontend (`kifi-app`)**
- **Framework:** Flutter
- **State Management:** Riverpod
- **Networking:** Dio for HTTP requests
- **Key Dependencies:** `file_picker`, `image_picker`, `path_provider`, `share_plus`, `fl_chart`
- **Architecture Pattern:** Feature-first modular structure (`auth`, `projects`, `transactions`, `business`, etc.) with separation of `data`, `domain`, `presentation`, and `providers`.
---
## 2. Recent Major Implementations
### **UI Standardization & Enterprise Polish**
- **Objective:** Strictly adhere to a uniform, enterprise-grade design system across all list screens (Projects, Invoices, Vendors, Task Board), using the `CustomersScreen` as the source of truth.
- **Changes made:**
- Standardized background colors to `Colors.grey[100]` across the app.
- Implemented uniform search fields: white container, `grey[100]` fill, rounded corners, no borders.
- Redesigned the `TaskCard` and task columns in the `ProjectBoardScreen` to look modern and functional.
### **Task Comments & File Attachments**
- **Initial State:** Task comments only supported base64 encoded images stored directly in the PostgreSQL `TEXT` column.
- **New Feature:** Implemented file attachments (PDFs, Docs, Images) for task comments with native downloading and sharing.
- **Backend Refactor:**
- Integrated `MinioServiceClient` into `ProjectService`.
- Modified `addComment` to intercept incoming base64 payloads, upload them to Minio in parallel using reactive streams (`Flux`), and save a JSON metadata string (`[{"fileName": "...", "contentType": "...", "filePath": "..."}]`) into the database.
- Added a generic file download endpoint: `GET /projects/tasks/attachments/download`.
- **Frontend Refactor:**
- Added `file_picker` dependency to support non-image documents.
- Rebuilt the attachment picking UI in `TaskDetailsSheet` to support both images and generic files.
- Implemented a secure download mechanism using `dio`, `path_provider`, and `share_plus` to save and open documents natively.
- Added an interactive image gallery (`AttachmentGalleryScreen`) for previewing image attachments (both legacy base64 and new Minio-backed URLs).
---
## 3. Current State & Known Behaviors
- **Backward Compatibility:** The backend and frontend correctly handle "legacy" task comments that were saved purely as Base64 strings, alongside the new Minio-backed JSON structure.
- **Application Configuration:**
- Max in-memory size for Spring WebFlux was increased to `10MB` in `application.yml` to allow for large base64-encoded file uploads before they are dispatched to Minio.
- **Outstanding Items:**
- Since background services were interrupted, you may need to ensure your Minio container and Redis instance are up and running before testing the new upload flow.
---
## 4. Setup & Running Instructions
### **Backend**
1. Navigate to the backend directory:
```bash
cd kifi-api
```
2. Ensure your local PostgreSQL, Redis, and Minio instances are running.
3. Clean and run the Spring Boot application:
```bash
./mvnw clean spring-boot:run
```
### **Frontend**
1. Navigate to the app directory:
```bash
cd kifi-app
```
2. Fetch new dependencies (especially the newly added `file_picker`):
```bash
flutter pub get
```
3. Run the app on your emulator or connected device:
```bash
flutter run
```
> [!WARNING]
> Because new native dependencies (`file_picker`, `share_plus`) were added during the last session, hot-reloading will not work for these changes. **You must stop the Flutter application completely and perform a full `flutter run`** to compile the native platform channels.

1070
prompt.md

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +0,0 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
public class test_api {
public static void main(String[] args) {
// Can't easily test without JWT. Let's just create a test controller in the backend!
}
}

View File

@@ -1,4 +0,0 @@
#!/bin/bash
TOKEN=$(sqlite3 /Users/maddy/Projects/Kifi/kifi-api/kifi.db 'select token from some_table limit 1' 2>/dev/null)
# Let's just create a test controller endpoint that doesn't need auth, or use JWT generator if I had one.
# Wait, I don't have a valid JWT. I can just write a quick test inside WalletController!

Binary file not shown.

View File

@@ -1,12 +0,0 @@
import reactor.core.publisher.Mono;
public class test_mono {
public static void main(String[] args) {
Mono<String> source = Mono.empty();
Mono<String> m = source
.flatMap(s -> Mono.just("from flatMap " + s))
.switchIfEmpty(Mono.defer(() -> Mono.just("from switchIfEmpty")));
m.subscribe(System.out::println);
}
}

3
ui-rule-prompt.md Normal file
View File

@@ -0,0 +1,3 @@
Redesign this screen using the UI/UX rules from the skill.
Do not use a generic Material layout.
Keep the existing functionality unchanged.