Revamp stage one - individual account and account setup fixed
This commit is contained in:
2083
.agents/skills/antigravity-senior-architect/SKILL.md
Normal file
2083
.agents/skills/antigravity-senior-architect/SKILL.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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()))));
|
||||
}
|
||||
}
|
||||
23
kifi-api/src/main/java/com/kifi/api/dto/SetupRequestDTO.java
Normal file
23
kifi-api/src/main/java/com/kifi/api/dto/SetupRequestDTO.java
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
29
kifi-api/src/main/java/com/kifi/api/entity/UserConsent.java
Normal file
29
kifi-api/src/main/java/com/kifi/api/entity/UserConsent.java
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
114
kifi-api/src/main/java/com/kifi/api/service/SetupService.java
Normal file
114
kifi-api/src/main/java/com/kifi/api/service/SetupService.java
Normal 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
46
kifi-api/src/main/java/com/kifi/api/util/CryptoUtils.java
Normal file
46
kifi-api/src/main/java/com/kifi/api/util/CryptoUtils.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ spring:
|
||||
|
||||
sql:
|
||||
init:
|
||||
mode: never
|
||||
mode: always
|
||||
schema-locations: classpath:schema.sql
|
||||
|
||||
data:
|
||||
|
||||
29
kifi-api/src/main/resources/policies/privacy-policy.html
Normal file
29
kifi-api/src/main/resources/policies/privacy-policy.html
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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
BIN
kifi-app/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
3
kifi-app/devtools_options.yaml
Normal file
3
kifi-app/devtools_options.yaml
Normal 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:
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
)),
|
||||
|
||||
@@ -35,4 +35,8 @@ class CryptoService {
|
||||
}
|
||||
|
||||
bool get isInitialized => _publicKey != null;
|
||||
|
||||
void clearKey() {
|
||||
_publicKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
21
kifi-app/lib/core/utils/masking_utils.dart
Normal file
21
kifi-app/lib/core/utils/masking_utils.dart
Normal 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)}';
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
501
kifi-app/lib/features/auth/presentation/setup_wizard_screen.dart
Normal file
501
kifi-app/lib/features/auth/presentation/setup_wizard_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
109
kifi_handoff.md
109
kifi_handoff.md
@@ -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.
|
||||
|
||||
2207
promptv2.md
2207
promptv2.md
File diff suppressed because it is too large
Load Diff
@@ -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!
|
||||
}
|
||||
}
|
||||
@@ -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!
|
||||
BIN
test_mono.class
BIN
test_mono.class
Binary file not shown.
@@ -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
3
ui-rule-prompt.md
Normal 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.
|
||||
Reference in New Issue
Block a user