Project management related changes
This commit is contained in:
@@ -0,0 +1,138 @@
|
|||||||
|
package com.kifi.api.controller;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.project.Project;
|
||||||
|
import com.kifi.api.entity.project.ProjectTask;
|
||||||
|
import com.kifi.api.entity.project.ProjectTaskComment;
|
||||||
|
import com.kifi.api.service.ProjectService;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/kifi-v2/projects")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ProjectController {
|
||||||
|
|
||||||
|
private final ProjectService projectService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Flux<Project> getProjects(Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return projectService.getProjectsByUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public Mono<Project> createProject(@RequestBody CreateProjectRequest request, Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return projectService.createProject(
|
||||||
|
userId,
|
||||||
|
request.getName(),
|
||||||
|
request.getDescription(),
|
||||||
|
request.getCustomerId(),
|
||||||
|
request.getBudget()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{projectId}")
|
||||||
|
public Mono<Project> updateProject(@PathVariable Long projectId, @RequestBody CreateProjectRequest request, Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return projectService.updateProject(
|
||||||
|
projectId,
|
||||||
|
userId,
|
||||||
|
request.getName(),
|
||||||
|
request.getDescription(),
|
||||||
|
request.getCustomerId(),
|
||||||
|
request.getBudget(),
|
||||||
|
request.getStatus()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{projectId}")
|
||||||
|
public Mono<Void> deleteProject(@PathVariable Long projectId, Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return projectService.deleteProject(projectId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{projectId}/tasks")
|
||||||
|
public Flux<ProjectTask> getTasks(@PathVariable Long projectId) {
|
||||||
|
return projectService.getTasksByProject(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{projectId}/tasks")
|
||||||
|
public Mono<ProjectTask> createTask(@PathVariable Long projectId, @RequestBody CreateTaskRequest request) {
|
||||||
|
return projectService.createTask(
|
||||||
|
projectId,
|
||||||
|
request.getTitle(),
|
||||||
|
request.getDescription(),
|
||||||
|
request.getAssigneeName(),
|
||||||
|
request.getAssigneeUserId(),
|
||||||
|
request.getIsBillable(),
|
||||||
|
request.getHourlyRate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/tasks/{taskId}")
|
||||||
|
public Mono<ProjectTask> updateTask(@PathVariable Long taskId, @RequestBody CreateTaskRequest request) {
|
||||||
|
return projectService.updateTask(
|
||||||
|
taskId,
|
||||||
|
request.getTitle(),
|
||||||
|
request.getDescription(),
|
||||||
|
request.getAssigneeName(),
|
||||||
|
request.getAssigneeUserId(),
|
||||||
|
request.getStatus(),
|
||||||
|
request.getIsBillable(),
|
||||||
|
request.getHourlyRate(),
|
||||||
|
request.getHoursLogged()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/tasks/{taskId}")
|
||||||
|
public Mono<Void> deleteTask(@PathVariable Long taskId) {
|
||||||
|
return projectService.deleteTask(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@GetMapping("/tasks/{taskId}/comments")
|
||||||
|
public Flux<ProjectTaskComment> getComments(@PathVariable Long taskId) {
|
||||||
|
return projectService.getCommentsForTask(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/tasks/{taskId}/comments")
|
||||||
|
public Mono<ProjectTaskComment> addComment(
|
||||||
|
@PathVariable Long taskId,
|
||||||
|
@RequestBody CreateCommentRequest request,
|
||||||
|
Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return projectService.addComment(taskId, userId, request.getContent(), request.getImagesJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
static class CreateProjectRequest {
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
private String description;
|
||||||
|
private Long customerId;
|
||||||
|
private java.math.BigDecimal budget;
|
||||||
|
private String status;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
static class CreateTaskRequest {
|
||||||
|
private String title;
|
||||||
|
private String description;
|
||||||
|
private String assigneeName;
|
||||||
|
private Long assigneeUserId;
|
||||||
|
private String status;
|
||||||
|
private Boolean isBillable;
|
||||||
|
private java.math.BigDecimal hourlyRate;
|
||||||
|
private java.math.BigDecimal hoursLogged;
|
||||||
|
}
|
||||||
|
@Data
|
||||||
|
static class CreateCommentRequest {
|
||||||
|
private String content;
|
||||||
|
private String imagesJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.kifi.api.controller;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.User;
|
||||||
|
import com.kifi.api.service.UserService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/kifi-v2/users")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserController {
|
||||||
|
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
@GetMapping("/search")
|
||||||
|
public Mono<ResponseEntity<List<User>>> searchUsers(@RequestParam String q) {
|
||||||
|
return userService.searchUsers(q)
|
||||||
|
.collectList()
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.kifi.api.entity.project;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Table("projects")
|
||||||
|
public class Project {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private String name;
|
||||||
|
private String description;
|
||||||
|
private Long customerId;
|
||||||
|
private Long userId;
|
||||||
|
private java.math.BigDecimal budget;
|
||||||
|
private String status;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.kifi.api.entity.project;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Table("project_tasks")
|
||||||
|
public class ProjectTask {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private Long projectId;
|
||||||
|
private String title;
|
||||||
|
private String description;
|
||||||
|
private String assigneeName;
|
||||||
|
private Long assigneeUserId;
|
||||||
|
private String status;
|
||||||
|
private Boolean isBillable;
|
||||||
|
private java.math.BigDecimal hourlyRate;
|
||||||
|
private java.math.BigDecimal hoursLogged;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.kifi.api.entity.project;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Table("project_task_comments")
|
||||||
|
public class ProjectTaskComment {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private Long taskId;
|
||||||
|
private Long userId;
|
||||||
|
private String content;
|
||||||
|
private String images;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.kifi.api.repository;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.project.Project;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
public interface ProjectRepository extends ReactiveCrudRepository<Project, Long> {
|
||||||
|
Flux<Project> findByUserId(Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.kifi.api.repository;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.project.ProjectTaskComment;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
public interface ProjectTaskCommentRepository extends ReactiveCrudRepository<ProjectTaskComment, Long> {
|
||||||
|
Flux<ProjectTaskComment> findByTaskIdOrderByCreatedAtAsc(Long taskId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.kifi.api.repository;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.project.ProjectTask;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
public interface ProjectTaskRepository extends ReactiveCrudRepository<ProjectTask, Long> {
|
||||||
|
Flux<ProjectTask> findByProjectId(Long projectId);
|
||||||
|
}
|
||||||
@@ -10,4 +10,5 @@ import reactor.core.publisher.Mono;
|
|||||||
@Repository
|
@Repository
|
||||||
public interface UserRepository extends R2dbcRepository<User, Long> {
|
public interface UserRepository extends R2dbcRepository<User, Long> {
|
||||||
Mono<User> findByEmail(String email);
|
Mono<User> findByEmail(String email);
|
||||||
|
Flux<User> findByEmailContainingIgnoreCase(String email);
|
||||||
}
|
}
|
||||||
|
|||||||
117
kifi-api/src/main/java/com/kifi/api/service/ProjectService.java
Normal file
117
kifi-api/src/main/java/com/kifi/api/service/ProjectService.java
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package com.kifi.api.service;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.project.Project;
|
||||||
|
import com.kifi.api.entity.project.ProjectTask;
|
||||||
|
import com.kifi.api.entity.project.ProjectTaskComment;
|
||||||
|
import com.kifi.api.repository.ProjectRepository;
|
||||||
|
import com.kifi.api.repository.ProjectTaskRepository;
|
||||||
|
import com.kifi.api.repository.ProjectTaskCommentRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class ProjectService {
|
||||||
|
|
||||||
|
private final ProjectRepository projectRepository;
|
||||||
|
private final ProjectTaskRepository projectTaskRepository;
|
||||||
|
private final ProjectTaskCommentRepository projectTaskCommentRepository;
|
||||||
|
|
||||||
|
public Flux<Project> getProjectsByUser(Long userId) {
|
||||||
|
return projectRepository.findByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Project> createProject(Long userId, String name, String description, Long customerId, java.math.BigDecimal budget) {
|
||||||
|
Project p = new Project();
|
||||||
|
p.setUserId(userId);
|
||||||
|
p.setName(name);
|
||||||
|
p.setDescription(description);
|
||||||
|
p.setCustomerId(customerId);
|
||||||
|
p.setBudget(budget != null ? budget : java.math.BigDecimal.ZERO);
|
||||||
|
p.setStatus("ACTIVE");
|
||||||
|
p.setCreatedAt(LocalDateTime.now());
|
||||||
|
return projectRepository.save(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Project> updateProject(Long projectId, Long userId, String name, String description, Long customerId, java.math.BigDecimal budget, String status) {
|
||||||
|
return projectRepository.findById(projectId)
|
||||||
|
.filter(p -> p.getUserId().equals(userId))
|
||||||
|
.flatMap(p -> {
|
||||||
|
if (name != null) p.setName(name);
|
||||||
|
if (description != null) p.setDescription(description);
|
||||||
|
if (customerId != null) p.setCustomerId(customerId);
|
||||||
|
if (budget != null) p.setBudget(budget);
|
||||||
|
if (status != null) p.setStatus(status);
|
||||||
|
return projectRepository.save(p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> deleteProject(Long projectId, Long userId) {
|
||||||
|
return projectRepository.findById(projectId)
|
||||||
|
.filter(p -> p.getUserId().equals(userId))
|
||||||
|
.flatMap(p -> projectTaskRepository.findByProjectId(projectId)
|
||||||
|
.flatMap(task -> projectTaskRepository.delete(task))
|
||||||
|
.then(projectRepository.delete(p))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<ProjectTask> getTasksByProject(Long projectId) {
|
||||||
|
return projectTaskRepository.findByProjectId(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<ProjectTask> createTask(Long projectId, String title, String description, String assigneeName, Long assigneeUserId, Boolean isBillable, java.math.BigDecimal hourlyRate) {
|
||||||
|
ProjectTask t = new ProjectTask();
|
||||||
|
t.setProjectId(projectId);
|
||||||
|
t.setTitle(title);
|
||||||
|
t.setDescription(description);
|
||||||
|
t.setAssigneeName(assigneeName);
|
||||||
|
t.setAssigneeUserId(assigneeUserId);
|
||||||
|
t.setStatus("TODO");
|
||||||
|
t.setIsBillable(isBillable != null ? isBillable : false);
|
||||||
|
t.setHourlyRate(hourlyRate != null ? hourlyRate : java.math.BigDecimal.ZERO);
|
||||||
|
t.setHoursLogged(java.math.BigDecimal.ZERO);
|
||||||
|
t.setCreatedAt(LocalDateTime.now());
|
||||||
|
return projectTaskRepository.save(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<ProjectTask> updateTask(Long taskId, String title, String description, String assigneeName, Long assigneeUserId, String status, Boolean isBillable, java.math.BigDecimal hourlyRate, java.math.BigDecimal hoursLogged) {
|
||||||
|
return projectTaskRepository.findById(taskId)
|
||||||
|
.flatMap(t -> {
|
||||||
|
if (title != null) t.setTitle(title);
|
||||||
|
if (description != null) t.setDescription(description);
|
||||||
|
if (assigneeName != null) t.setAssigneeName(assigneeName);
|
||||||
|
if (assigneeUserId != null) t.setAssigneeUserId(assigneeUserId);
|
||||||
|
if (status != null) t.setStatus(status);
|
||||||
|
if (isBillable != null) t.setIsBillable(isBillable);
|
||||||
|
if (hourlyRate != null) t.setHourlyRate(hourlyRate);
|
||||||
|
if (hoursLogged != null) t.setHoursLogged(hoursLogged);
|
||||||
|
return projectTaskRepository.save(t);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> deleteTask(Long taskId) {
|
||||||
|
return projectTaskRepository.deleteById(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<ProjectTaskComment> getCommentsForTask(Long taskId) {
|
||||||
|
return projectTaskCommentRepository.findByTaskIdOrderByCreatedAtAsc(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<ProjectTaskComment> addComment(Long taskId, Long userId, String content, String imagesJson) {
|
||||||
|
ProjectTaskComment comment = new ProjectTaskComment();
|
||||||
|
comment.setTaskId(taskId);
|
||||||
|
comment.setUserId(userId);
|
||||||
|
comment.setContent(content);
|
||||||
|
if (imagesJson != null && !imagesJson.isEmpty()) {
|
||||||
|
comment.setImages(imagesJson);
|
||||||
|
}
|
||||||
|
comment.setCreatedAt(LocalDateTime.now());
|
||||||
|
return projectTaskCommentRepository.save(comment);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
kifi-api/src/main/java/com/kifi/api/service/UserService.java
Normal file
25
kifi-api/src/main/java/com/kifi/api/service/UserService.java
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package com.kifi.api.service;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.User;
|
||||||
|
import com.kifi.api.repository.UserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserService {
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public Flux<User> searchUsers(String query) {
|
||||||
|
if (query == null || query.isBlank()) {
|
||||||
|
return Flux.empty();
|
||||||
|
}
|
||||||
|
return userRepository.findByEmailContainingIgnoreCase(query)
|
||||||
|
.map(user -> {
|
||||||
|
// Ensure password is not returned in search results
|
||||||
|
user.setPassword(null);
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: kifi-api
|
name: kifi-api
|
||||||
|
|
||||||
|
codec:
|
||||||
|
max-in-memory-size: 10MB
|
||||||
|
|
||||||
r2dbc:
|
r2dbc:
|
||||||
url: r2dbc:postgresql://103.125.129.116:5333/kifi-v2
|
url: r2dbc:postgresql://103.125.129.116:5333/kifi-v2
|
||||||
|
|||||||
@@ -364,3 +364,37 @@ CREATE TABLE IF NOT EXISTS invoice_payments (
|
|||||||
emi_installment_number INTEGER,
|
emi_installment_number INTEGER,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
customer_id INTEGER REFERENCES customers(id),
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
budget DECIMAL(15, 2) DEFAULT 0.00,
|
||||||
|
status VARCHAR(50) DEFAULT 'ACTIVE',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_tasks (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
assignee_name VARCHAR(255),
|
||||||
|
assignee_user_id INTEGER REFERENCES users(id),
|
||||||
|
status VARCHAR(50) DEFAULT 'TODO', -- TODO, IN_PROGRESS, REVIEW, DONE
|
||||||
|
is_billable BOOLEAN DEFAULT false,
|
||||||
|
hourly_rate DECIMAL(15, 2) DEFAULT 0.00,
|
||||||
|
hours_logged DECIMAL(10, 2) DEFAULT 0.00,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_task_comments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
task_id INTEGER REFERENCES project_tasks(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
images TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|||||||
15
kifi-app/lib/core/domain/user.dart
Normal file
15
kifi-app/lib/core/domain/user.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
class User {
|
||||||
|
final int id;
|
||||||
|
final String name;
|
||||||
|
final String email;
|
||||||
|
|
||||||
|
User({required this.id, required this.name, required this.email});
|
||||||
|
|
||||||
|
factory User.fromJson(Map<String, dynamic> json) {
|
||||||
|
return User(
|
||||||
|
id: json['id'],
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
email: json['email'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@ class DioClient {
|
|||||||
|
|
||||||
DioClient._internal()
|
DioClient._internal()
|
||||||
: dio = Dio(BaseOptions(
|
: dio = Dio(BaseOptions(
|
||||||
baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2',
|
//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: 'http://192.168.0.104:8080/api/kifi-v2', // Local Mac IP for physical device testing
|
||||||
connectTimeout: const Duration(seconds: 10),
|
connectTimeout: const Duration(seconds: 10),
|
||||||
receiveTimeout: const Duration(seconds: 10),
|
receiveTimeout: const Duration(seconds: 10),
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
class PremiumTextField extends StatelessWidget {
|
class PremiumTextField extends StatelessWidget {
|
||||||
final String labelText;
|
final String labelText;
|
||||||
final TextEditingController? controller;
|
final TextEditingController? controller;
|
||||||
|
final FocusNode? focusNode;
|
||||||
final String? initialValue;
|
final String? initialValue;
|
||||||
final Widget? prefixIcon;
|
final Widget? prefixIcon;
|
||||||
final Widget? suffixIcon;
|
final Widget? suffixIcon;
|
||||||
@@ -20,6 +21,7 @@ class PremiumTextField extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.labelText,
|
required this.labelText,
|
||||||
this.controller,
|
this.controller,
|
||||||
|
this.focusNode,
|
||||||
this.initialValue,
|
this.initialValue,
|
||||||
this.prefixIcon,
|
this.prefixIcon,
|
||||||
this.suffixIcon,
|
this.suffixIcon,
|
||||||
@@ -38,6 +40,7 @@ class PremiumTextField extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return TextFormField(
|
return TextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
initialValue: initialValue,
|
initialValue: initialValue,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: labelText,
|
labelText: labelText,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../../transactions/providers/providers.dart';
|
|||||||
|
|
||||||
import '../../../core/theme/theme_provider.dart';
|
import '../../../core/theme/theme_provider.dart';
|
||||||
import '../../business/providers/business_mode_provider.dart';
|
import '../../business/providers/business_mode_provider.dart';
|
||||||
|
import '../../projects/providers/project_mode_provider.dart';
|
||||||
import '../../business/presentation/settings/business_settings_screen.dart';
|
import '../../business/presentation/settings/business_settings_screen.dart';
|
||||||
|
|
||||||
class ProfileScreen extends ConsumerStatefulWidget {
|
class ProfileScreen extends ConsumerStatefulWidget {
|
||||||
@@ -141,7 +142,22 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const BusinessSettingsScreen()));
|
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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../projects/presentation/project_hub_screen.dart';
|
||||||
|
import '../../projects/presentation/projects_screen.dart';
|
||||||
|
import '../../projects/providers/project_mode_provider.dart';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
@@ -150,6 +154,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
final walletsState = ref.watch(walletProvider);
|
final walletsState = ref.watch(walletProvider);
|
||||||
final invoicesState = ref.watch(invoicesProvider);
|
final invoicesState = ref.watch(invoicesProvider);
|
||||||
final customersState = ref.watch(customersProvider);
|
final customersState = ref.watch(customersProvider);
|
||||||
|
final isProjectMode = ref.watch(projectModeProvider);
|
||||||
final isBusinessMode = ref.watch(businessModeProvider);
|
final isBusinessMode = ref.watch(businessModeProvider);
|
||||||
final allTransactions = transState.value ?? [];
|
final allTransactions = transState.value ?? [];
|
||||||
|
|
||||||
@@ -157,18 +162,21 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
final startDate = range.start;
|
final startDate = range.start;
|
||||||
final endDate = range.end;
|
final endDate = range.end;
|
||||||
|
|
||||||
String title;
|
String title = 'Dashboard';
|
||||||
if (_currentIndex == 0) {
|
int logicalIndex = _currentIndex;
|
||||||
title = 'Dashboard';
|
if (logicalIndex == 0) title = 'Dashboard';
|
||||||
} else if (isBusinessMode) {
|
else {
|
||||||
if (_currentIndex == 1) title = 'Business Hub';
|
if (isProjectMode) {
|
||||||
else if (_currentIndex == 2) title = 'Statistics';
|
if (logicalIndex == 1) title = 'Projects';
|
||||||
else if (_currentIndex == 3) title = 'My Accounts';
|
logicalIndex--;
|
||||||
else title = 'Budgets';
|
}
|
||||||
} else {
|
if (isBusinessMode) {
|
||||||
if (_currentIndex == 1) title = 'Statistics';
|
if (logicalIndex == 1) title = 'Business Hub';
|
||||||
else if (_currentIndex == 2) title = 'My Accounts';
|
logicalIndex--;
|
||||||
else title = 'Budgets';
|
}
|
||||||
|
if (logicalIndex == 1) title = 'Statistics';
|
||||||
|
else if (logicalIndex == 2) title = 'My Accounts';
|
||||||
|
else if (logicalIndex == 3) title = 'Budgets';
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -400,11 +408,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return IndexedStack(
|
final List<Widget> screens = [];
|
||||||
index: _currentIndex,
|
|
||||||
children: [
|
screens.add(RefreshIndicator(
|
||||||
// ---------------- HOME TAB ----------------
|
|
||||||
RefreshIndicator(
|
|
||||||
onRefresh: _onRefresh,
|
onRefresh: _onRefresh,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
@@ -624,13 +630,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
));
|
||||||
|
|
||||||
// ---------------- BUSINESS TAB ----------------
|
if (isProjectMode) {
|
||||||
if (isBusinessMode) const BusinessHubScreen(),
|
screens.add(const ProjectHubScreen());
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- STATS TAB ----------------
|
if (isBusinessMode) {
|
||||||
StatisticsTab(
|
screens.add(const BusinessHubScreen());
|
||||||
|
}
|
||||||
|
|
||||||
|
screens.add(StatisticsTab(
|
||||||
transactions: transactions,
|
transactions: transactions,
|
||||||
wallets: safeWallets,
|
wallets: safeWallets,
|
||||||
categories: categoriesState.value ?? [],
|
categories: categoriesState.value ?? [],
|
||||||
@@ -643,47 +653,64 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
},
|
},
|
||||||
onPickCustomDateRange: _pickCustomDateRange,
|
onPickCustomDateRange: _pickCustomDateRange,
|
||||||
onRefresh: _onRefresh,
|
onRefresh: _onRefresh,
|
||||||
),
|
));
|
||||||
|
screens.add(const AccountsScreen());
|
||||||
// ---------------- WALLETS TAB ----------------
|
screens.add(const BudgetScreen());
|
||||||
const AccountsScreen(),
|
|
||||||
|
int safeIndex = _currentIndex;
|
||||||
// ---------------- BUDGETS TAB ----------------
|
if (safeIndex >= screens.length) safeIndex = screens.length - 1;
|
||||||
const BudgetScreen(),
|
|
||||||
],
|
return IndexedStack(
|
||||||
|
index: safeIndex,
|
||||||
|
children: screens,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
bottomNavigationBar: BottomNavigationBar(
|
bottomNavigationBar: Builder(
|
||||||
currentIndex: isBusinessMode
|
builder: (context) {
|
||||||
? (_currentIndex >= 3 ? _currentIndex + 1 : _currentIndex)
|
final isProjectMode = ref.watch(projectModeProvider);
|
||||||
: (_currentIndex >= 2 ? _currentIndex + 1 : _currentIndex),
|
final isBusinessMode = ref.watch(businessModeProvider);
|
||||||
type: BottomNavigationBarType.fixed,
|
final List<BottomNavigationBarItem> navItems = [];
|
||||||
onTap: (index) {
|
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'));
|
||||||
final addIndex = isBusinessMode ? 3 : 2;
|
if (isProjectMode) navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.trello), label: 'Projects'));
|
||||||
if (index == addIndex) {
|
if (isBusinessMode) navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.briefcase), label: 'Business'));
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen()));
|
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'));
|
||||||
} else {
|
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'));
|
||||||
setState(() {
|
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'));
|
||||||
_currentIndex = index > addIndex ? index - 1 : index;
|
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'));
|
||||||
});
|
|
||||||
}
|
int safeIndex = _currentIndex;
|
||||||
},
|
// Adjust _currentIndex for the display of bottom nav bar because of 'Add' button
|
||||||
selectedItemColor: Theme.of(context).colorScheme.primary,
|
int addIdx = 1;
|
||||||
unselectedItemColor: Colors.grey,
|
if (isProjectMode) addIdx++;
|
||||||
showSelectedLabels: true,
|
if (isBusinessMode) addIdx++;
|
||||||
showUnselectedLabels: true,
|
addIdx++; // For Stats
|
||||||
items: [
|
|
||||||
const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'),
|
int displayIndex = _currentIndex;
|
||||||
if (isBusinessMode)
|
if (_currentIndex >= addIdx) displayIndex++;
|
||||||
const BottomNavigationBarItem(icon: Icon(LucideIcons.briefcase), label: 'Business'),
|
|
||||||
const BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'),
|
if (displayIndex >= navItems.length) displayIndex = navItems.length - 1;
|
||||||
const BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'),
|
|
||||||
const BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'),
|
return BottomNavigationBar(
|
||||||
const BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'),
|
currentIndex: displayIndex,
|
||||||
],
|
type: BottomNavigationBarType.fixed,
|
||||||
),
|
onTap: (index) {
|
||||||
);
|
if (index == addIdx) {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen()));
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_currentIndex = index > addIdx ? index - 1 : index;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedItemColor: Theme.of(context).colorScheme.primary,
|
||||||
|
unselectedItemColor: Colors.grey,
|
||||||
|
showSelectedLabels: true,
|
||||||
|
showUnselectedLabels: true,
|
||||||
|
items: navItems,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
), );
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSummaryCard(BuildContext context, String title, double amount, Color color, IconData icon, {VoidCallback? onTap}) {
|
Widget _buildSummaryCard(BuildContext context, String title, double amount, Color color, IconData icon, {VoidCallback? onTap}) {
|
||||||
|
|||||||
131
kifi-app/lib/features/projects/data/project_repository.dart
Normal file
131
kifi-app/lib/features/projects/data/project_repository.dart
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import '../domain/project.dart';
|
||||||
|
import '../domain/project_task.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
import '../domain/project_task_comment.dart';
|
||||||
|
|
||||||
|
class ProjectRepository {
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
ProjectRepository(this._dio);
|
||||||
|
|
||||||
|
Future<List<Project>> getProjects() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/projects');
|
||||||
|
if (response.data == null) return [];
|
||||||
|
return (response.data as List).map((p) => Project.fromJson(p)).toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.response?.statusCode == 404) {
|
||||||
|
throw Exception("Projects API endpoint not found. Please ensure the backend server is updated to the latest Docker image.");
|
||||||
|
}
|
||||||
|
throw Exception(e.message ?? 'Unknown error fetching projects');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Project> createProject({
|
||||||
|
required String name,
|
||||||
|
String? description,
|
||||||
|
int? customerId,
|
||||||
|
double? budget,
|
||||||
|
}) async {
|
||||||
|
final response = await _dio.post('/projects', data: {
|
||||||
|
'name': name,
|
||||||
|
if (description != null) 'description': description,
|
||||||
|
if (customerId != null) 'customerId': customerId,
|
||||||
|
if (budget != null) 'budget': budget,
|
||||||
|
});
|
||||||
|
return Project.fromJson(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Project> updateProject({
|
||||||
|
required int projectId,
|
||||||
|
String? name,
|
||||||
|
String? description,
|
||||||
|
int? customerId,
|
||||||
|
double? budget,
|
||||||
|
String? status,
|
||||||
|
}) async {
|
||||||
|
final response = await _dio.put('/projects/$projectId', data: {
|
||||||
|
if (name != null) 'name': name,
|
||||||
|
if (description != null) 'description': description,
|
||||||
|
if (customerId != null) 'customerId': customerId,
|
||||||
|
if (budget != null) 'budget': budget,
|
||||||
|
if (status != null) 'status': status,
|
||||||
|
});
|
||||||
|
return Project.fromJson(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteProject(int projectId) async {
|
||||||
|
await _dio.delete('/projects/$projectId');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<ProjectTask>> getTasks(int projectId) async {
|
||||||
|
final response = await _dio.get('/projects/$projectId/tasks');
|
||||||
|
return (response.data as List).map((t) => ProjectTask.fromJson(t)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ProjectTask> createTask({
|
||||||
|
required int projectId,
|
||||||
|
required String title,
|
||||||
|
String? description,
|
||||||
|
String? assigneeName,
|
||||||
|
int? assigneeUserId,
|
||||||
|
bool? isBillable,
|
||||||
|
double? hourlyRate,
|
||||||
|
}) async {
|
||||||
|
final response = await _dio.post('/projects/$projectId/tasks', data: {
|
||||||
|
'title': title,
|
||||||
|
if (description != null) 'description': description,
|
||||||
|
if (assigneeName != null) 'assigneeName': assigneeName,
|
||||||
|
if (assigneeUserId != null) 'assigneeUserId': assigneeUserId,
|
||||||
|
if (isBillable != null) 'isBillable': isBillable,
|
||||||
|
if (hourlyRate != null) 'hourlyRate': hourlyRate,
|
||||||
|
});
|
||||||
|
return ProjectTask.fromJson(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ProjectTask> updateTask({
|
||||||
|
required int taskId,
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
String? assigneeName,
|
||||||
|
int? assigneeUserId,
|
||||||
|
String? status,
|
||||||
|
bool? isBillable,
|
||||||
|
double? hourlyRate,
|
||||||
|
double? hoursLogged,
|
||||||
|
}) async {
|
||||||
|
final response = await _dio.put('/projects/tasks/$taskId', data: {
|
||||||
|
if (title != null) 'title': title,
|
||||||
|
if (description != null) 'description': description,
|
||||||
|
if (assigneeName != null) 'assigneeName': assigneeName,
|
||||||
|
if (assigneeUserId != null) 'assigneeUserId': assigneeUserId,
|
||||||
|
if (status != null) 'status': status,
|
||||||
|
if (isBillable != null) 'isBillable': isBillable,
|
||||||
|
if (hourlyRate != null) 'hourlyRate': hourlyRate,
|
||||||
|
if (hoursLogged != null) 'hoursLogged': hoursLogged,
|
||||||
|
});
|
||||||
|
return ProjectTask.fromJson(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteTask(int taskId) async {
|
||||||
|
await _dio.delete('/projects/tasks/$taskId');
|
||||||
|
}
|
||||||
|
Future<List<ProjectTaskComment>> getComments(int taskId) async {
|
||||||
|
final response = await _dio.get('/projects/tasks/$taskId/comments');
|
||||||
|
return (response.data as List).map((c) => ProjectTaskComment.fromJson(c)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ProjectTaskComment> addComment({
|
||||||
|
required int taskId,
|
||||||
|
required String content,
|
||||||
|
List<String>? imagesBase64,
|
||||||
|
}) async {
|
||||||
|
final response = await _dio.post('/projects/tasks/$taskId/comments', data: {
|
||||||
|
'content': content,
|
||||||
|
if (imagesBase64 != null && imagesBase64.isNotEmpty)
|
||||||
|
'imagesJson': jsonEncode(imagesBase64),
|
||||||
|
});
|
||||||
|
return ProjectTaskComment.fromJson(response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
kifi-app/lib/features/projects/domain/project.dart
Normal file
47
kifi-app/lib/features/projects/domain/project.dart
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
class Project {
|
||||||
|
final int id;
|
||||||
|
final String name;
|
||||||
|
final String? description;
|
||||||
|
final int? customerId;
|
||||||
|
final int? userId;
|
||||||
|
final double budget;
|
||||||
|
final String status;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
Project({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.description,
|
||||||
|
this.customerId,
|
||||||
|
this.userId,
|
||||||
|
this.budget = 0.0,
|
||||||
|
this.status = 'ACTIVE',
|
||||||
|
this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Project.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Project(
|
||||||
|
id: json['id'] as int,
|
||||||
|
name: json['name'] as String,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
customerId: json['customerId'] as int?,
|
||||||
|
userId: json['userId'] as int?,
|
||||||
|
budget: (json['budget'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
status: json['status'] as String? ?? 'ACTIVE',
|
||||||
|
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'description': description,
|
||||||
|
'customerId': customerId,
|
||||||
|
'userId': userId,
|
||||||
|
'budget': budget,
|
||||||
|
'status': status,
|
||||||
|
'createdAt': createdAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
59
kifi-app/lib/features/projects/domain/project_task.dart
Normal file
59
kifi-app/lib/features/projects/domain/project_task.dart
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
class ProjectTask {
|
||||||
|
final int id;
|
||||||
|
final int projectId;
|
||||||
|
final String title;
|
||||||
|
final String? description;
|
||||||
|
final String? assigneeName;
|
||||||
|
final int? assigneeUserId;
|
||||||
|
final String status;
|
||||||
|
final bool isBillable;
|
||||||
|
final double hourlyRate;
|
||||||
|
final double hoursLogged;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
ProjectTask({
|
||||||
|
required this.id,
|
||||||
|
required this.projectId,
|
||||||
|
required this.title,
|
||||||
|
this.description,
|
||||||
|
this.assigneeName,
|
||||||
|
this.assigneeUserId,
|
||||||
|
this.status = 'TODO',
|
||||||
|
this.isBillable = false,
|
||||||
|
this.hourlyRate = 0.0,
|
||||||
|
this.hoursLogged = 0.0,
|
||||||
|
this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ProjectTask.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ProjectTask(
|
||||||
|
id: json['id'] as int,
|
||||||
|
projectId: json['projectId'] as int,
|
||||||
|
title: json['title'] as String,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
assigneeName: json['assigneeName'] as String?,
|
||||||
|
assigneeUserId: json['assigneeUserId'] as int?,
|
||||||
|
status: json['status'] as String? ?? 'TODO',
|
||||||
|
isBillable: json['isBillable'] as bool? ?? false,
|
||||||
|
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
hoursLogged: (json['hoursLogged'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'projectId': projectId,
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'assigneeName': assigneeName,
|
||||||
|
'assigneeUserId': assigneeUserId,
|
||||||
|
'status': status,
|
||||||
|
'isBillable': isBillable,
|
||||||
|
'hourlyRate': hourlyRate,
|
||||||
|
'hoursLogged': hoursLogged,
|
||||||
|
'createdAt': createdAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
class ProjectTaskComment {
|
||||||
|
final int id;
|
||||||
|
final int taskId;
|
||||||
|
final int userId;
|
||||||
|
final String content;
|
||||||
|
final List<String> images;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
ProjectTaskComment({
|
||||||
|
required this.id,
|
||||||
|
required this.taskId,
|
||||||
|
required this.userId,
|
||||||
|
required this.content,
|
||||||
|
required this.images,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ProjectTaskComment.fromJson(Map<String, dynamic> json) {
|
||||||
|
List<String> parsedImages = [];
|
||||||
|
if (json['images'] != null) {
|
||||||
|
if (json['images'] is String) {
|
||||||
|
try {
|
||||||
|
parsedImages = List<String>.from(jsonDecode(json['images']));
|
||||||
|
} catch (_) {}
|
||||||
|
} else if (json['images'] is List) {
|
||||||
|
parsedImages = List<String>.from(json['images']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProjectTaskComment(
|
||||||
|
id: json['id'] as int,
|
||||||
|
taskId: json['taskId'] as int,
|
||||||
|
userId: json['userId'] as int,
|
||||||
|
content: json['content'] as String,
|
||||||
|
images: parsedImages,
|
||||||
|
createdAt: DateTime.parse(json['createdAt']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../domain/project.dart';
|
||||||
|
import '../providers/project_provider.dart';
|
||||||
|
import 'widgets/add_task_sheet.dart';
|
||||||
|
import 'widgets/task_card.dart';
|
||||||
|
import 'widgets/add_project_sheet.dart';
|
||||||
|
import '../../sales/presentation/invoice_builder_screen.dart';
|
||||||
|
import '../../sales/domain/invoice.dart';
|
||||||
|
|
||||||
|
class ProjectBoardScreen extends ConsumerStatefulWidget {
|
||||||
|
final Project project;
|
||||||
|
|
||||||
|
const ProjectBoardScreen({super.key, required this.project});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ProjectBoardScreen> createState() => _ProjectBoardScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProjectBoardScreenState extends ConsumerState<ProjectBoardScreen> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tasksAsync = ref.watch(projectTasksProvider(widget.project.id));
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.grey[100],
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(widget.project.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
elevation: 0,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.info),
|
||||||
|
tooltip: 'Project Details',
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => AddProjectSheet(project: widget.project),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: tasksAsync.when(
|
||||||
|
data: (tasks) {
|
||||||
|
final columns = [
|
||||||
|
_ColumnDef('To Do', 'TODO', Colors.blueGrey, LucideIcons.circle),
|
||||||
|
_ColumnDef('In Progress', 'IN_PROGRESS', Colors.blue, LucideIcons.loader),
|
||||||
|
_ColumnDef('Review', 'REVIEW', Colors.orange, LucideIcons.eye),
|
||||||
|
_ColumnDef('Done', 'DONE', Colors.green, LucideIcons.checkCircle),
|
||||||
|
];
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: columns.map((col) {
|
||||||
|
final columnTasks = tasks.where((t) => t.status == col.status).toList();
|
||||||
|
return _buildColumn(col, columnTasks, showAdd: col.status == 'TODO');
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildColumn(_ColumnDef col, List tasks, {bool showAdd = false}) {
|
||||||
|
return Container(
|
||||||
|
width: 290,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: col.color.withValues(alpha: 0.04),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: col.color.withValues(alpha: 0.12)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Column header
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: col.color.withValues(alpha: 0.08),
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(col.icon, size: 16, color: col.color),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
col.label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
color: col.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: col.color.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${tasks.length}',
|
||||||
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: col.color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Task cards
|
||||||
|
...tasks.map((t) => TaskCard(
|
||||||
|
task: t,
|
||||||
|
onEdit: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => AddTaskSheet(
|
||||||
|
projectId: widget.project.id,
|
||||||
|
task: t,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onGenerateInvoice: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => InvoiceBuilderScreen(
|
||||||
|
initialCustomerId: widget.project.customerId,
|
||||||
|
initialItems: [
|
||||||
|
InvoiceItem(
|
||||||
|
id: 0,
|
||||||
|
invoiceId: 0,
|
||||||
|
productId: null,
|
||||||
|
description: t.title,
|
||||||
|
quantity: t.hoursLogged,
|
||||||
|
unitPrice: t.hourlyRate,
|
||||||
|
total: t.hoursLogged * t.hourlyRate,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
|
||||||
|
if (showAdd)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: col.color,
|
||||||
|
side: BorderSide(color: col.color.withValues(alpha: 0.3)),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
),
|
||||||
|
icon: const Icon(LucideIcons.plus, size: 16),
|
||||||
|
label: const Text('Add Task', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => AddTaskSheet(projectId: widget.project.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ColumnDef {
|
||||||
|
final String label;
|
||||||
|
final String status;
|
||||||
|
final Color color;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const _ColumnDef(this.label, this.status, this.color, this.icon);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../../../../core/theme/nature_colors.dart';
|
||||||
|
import 'projects_screen.dart';
|
||||||
|
import '../../sales/presentation/invoices_list_screen.dart';
|
||||||
|
import '../../sales/presentation/customers_list_screen.dart';
|
||||||
|
import '../../vendor/presentation/vendors_list_screen.dart';
|
||||||
|
import '../../business/presentation/widgets/business_profile_form_sheet.dart';
|
||||||
|
|
||||||
|
class ProjectHubScreen extends ConsumerWidget {
|
||||||
|
const ProjectHubScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => const BusinessProfileFormSheet(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.layoutDashboard, color: Theme.of(context).colorScheme.primary, size: 32),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Agency OS', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
const Text('Manage projects, tasks, and client billing', style: TextStyle(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
childAspectRatio: 1.2,
|
||||||
|
children: [
|
||||||
|
_buildActionCard(
|
||||||
|
context,
|
||||||
|
'Projects & Tasks',
|
||||||
|
'Manage all projects',
|
||||||
|
LucideIcons.folderKanban,
|
||||||
|
Colors.blue,
|
||||||
|
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const ProjectsScreen())),
|
||||||
|
),
|
||||||
|
_buildActionCard(
|
||||||
|
context,
|
||||||
|
'Invoices',
|
||||||
|
'Client billing',
|
||||||
|
LucideIcons.receipt,
|
||||||
|
Colors.green,
|
||||||
|
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const InvoicesListScreen())),
|
||||||
|
),
|
||||||
|
_buildActionCard(
|
||||||
|
context,
|
||||||
|
'Clients',
|
||||||
|
'Manage clients',
|
||||||
|
LucideIcons.users,
|
||||||
|
NatureColors.getColor('RECEIVABLES'),
|
||||||
|
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomersListScreen())),
|
||||||
|
),
|
||||||
|
_buildActionCard(
|
||||||
|
context,
|
||||||
|
'Contractors',
|
||||||
|
'Manage vendors',
|
||||||
|
LucideIcons.truck,
|
||||||
|
Colors.orange,
|
||||||
|
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const VendorsListScreen())),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionCard(BuildContext context, String title, String subtitle, IconData icon, Color color, VoidCallback onTap) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.2)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 28),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(title, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(subtitle, style: TextStyle(color: color.withValues(alpha: 0.7), fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
kifi-app/lib/features/projects/presentation/projects_screen.dart
Normal file
133
kifi-app/lib/features/projects/presentation/projects_screen.dart
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../providers/project_provider.dart';
|
||||||
|
import 'project_board_screen.dart';
|
||||||
|
import 'widgets/add_project_sheet.dart';
|
||||||
|
|
||||||
|
class ProjectsScreen extends ConsumerStatefulWidget {
|
||||||
|
const ProjectsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ProjectsScreen> createState() => _ProjectsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProjectsScreenState extends ConsumerState<ProjectsScreen> {
|
||||||
|
String _searchQuery = "";
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final projectsAsync = ref.watch(projectsProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.grey[100],
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Projects', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
color: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: TextField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search projects...',
|
||||||
|
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[100],
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() => _searchQuery = val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: projectsAsync.when(
|
||||||
|
data: (projects) {
|
||||||
|
final filteredProjects = projects.where((p) => p.name.toLowerCase().contains(_searchQuery.toLowerCase())).toList();
|
||||||
|
if (filteredProjects.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.folderKanban, size: 48, color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text('No projects found.', style: TextStyle(color: Colors.grey.shade500)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: filteredProjects.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final p = filteredProjects[index];
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
elevation: 0,
|
||||||
|
color: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
subtitle: Text(p.description ?? 'No description', style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(LucideIcons.edit, size: 18, color: Colors.grey.shade600),
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => AddProjectSheet(project: p),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Icon(Icons.chevron_right, color: Colors.grey.shade400),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => ProjectBoardScreen(project: p),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => const AddProjectSheet(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Add Project'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../domain/project.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../../../sales/presentation/add_customer_sheet.dart';
|
||||||
|
import '../../providers/project_provider.dart';
|
||||||
|
import '../../../sales/providers/customers_provider.dart';
|
||||||
|
import '../../../../core/widgets/premium_text_field.dart';
|
||||||
|
|
||||||
|
class AddProjectSheet extends ConsumerStatefulWidget {
|
||||||
|
final Project? project;
|
||||||
|
const AddProjectSheet({super.key, this.project});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AddProjectSheet> createState() => _AddProjectSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddProjectSheetState extends ConsumerState<AddProjectSheet> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
String _name = '';
|
||||||
|
String _description = '';
|
||||||
|
double _budget = 0;
|
||||||
|
int? _selectedCustomerId;
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.project != null) {
|
||||||
|
_name = widget.project!.name;
|
||||||
|
_description = widget.project!.description ?? '';
|
||||||
|
_budget = widget.project!.budget ?? 0;
|
||||||
|
_selectedCustomerId = widget.project!.customerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final customersState = ref.watch(customersProvider);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
left: 24,
|
||||||
|
right: 24,
|
||||||
|
top: 24,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(widget.project == null ? 'New Project' : 'Edit Project', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PremiumTextField(
|
||||||
|
labelText: 'Project Name',
|
||||||
|
initialValue: _name,
|
||||||
|
prefixIcon: const Icon(LucideIcons.folder),
|
||||||
|
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
|
||||||
|
onSaved: (val) => _name = val!,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PremiumTextField(
|
||||||
|
maxLines: 3,
|
||||||
|
labelText: 'Description (Optional)',
|
||||||
|
initialValue: _description,
|
||||||
|
prefixIcon: const Icon(LucideIcons.alignLeft),
|
||||||
|
onSaved: (val) => _description = val ?? '',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (customersState.hasValue)
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: DropdownButtonFormField<int>(
|
||||||
|
value: _selectedCustomerId,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Client (Optional)',
|
||||||
|
prefixIcon: const Icon(LucideIcons.users),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.05),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<int>(value: null, child: Text('None')),
|
||||||
|
...customersState.value!.map((c) => DropdownMenuItem<int>(
|
||||||
|
value: c.id,
|
||||||
|
child: Text(c.name),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCustomerId = val;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
height: 52, // Match the height of the dropdown approximately
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(LucideIcons.plus),
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
tooltip: 'Add New Client',
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => const AddCustomerSheet(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PremiumTextField(
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
labelText: 'Budget (Optional)',
|
||||||
|
initialValue: _budget > 0 ? _budget.toString() : '',
|
||||||
|
prefixIcon: const Icon(LucideIcons.indianRupee),
|
||||||
|
onSaved: (val) => _budget = double.tryParse(val ?? '0') ?? 0,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
onPressed: _isLoading ? null : _submit,
|
||||||
|
child: _isLoading ? const CircularProgressIndicator(color: Colors.white) : Text(widget.project == null ? 'Create Project' : 'Save Changes', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
_formKey.currentState!.save();
|
||||||
|
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
final repo = ref.read(projectRepositoryProvider);
|
||||||
|
if (widget.project != null) {
|
||||||
|
await repo.updateProject(
|
||||||
|
projectId: widget.project!.id,
|
||||||
|
name: _name,
|
||||||
|
description: _description.isNotEmpty ? _description : null,
|
||||||
|
customerId: _selectedCustomerId,
|
||||||
|
budget: _budget,
|
||||||
|
status: widget.project!.status,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await repo.createProject(
|
||||||
|
name: _name,
|
||||||
|
description: _description.isNotEmpty ? _description : null,
|
||||||
|
customerId: _selectedCustomerId,
|
||||||
|
budget: _budget,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ref.invalidate(projectsProvider);
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../core/widgets/premium_text_field.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../../domain/project_task.dart';
|
||||||
|
import '../../providers/project_provider.dart';
|
||||||
|
import '../../providers/user_provider.dart';
|
||||||
|
import '../../../../core/domain/user.dart';
|
||||||
|
|
||||||
|
class AddTaskSheet extends ConsumerStatefulWidget {
|
||||||
|
final int projectId;
|
||||||
|
final ProjectTask? task;
|
||||||
|
|
||||||
|
const AddTaskSheet({super.key, required this.projectId, this.task});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AddTaskSheet> createState() => _AddTaskSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddTaskSheetState extends ConsumerState<AddTaskSheet> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
String _title = '';
|
||||||
|
String _description = '';
|
||||||
|
String _assigneeName = '';
|
||||||
|
bool _isBillable = false;
|
||||||
|
double _hourlyRate = 0;
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
bool get _isEditing => widget.task != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.task != null) {
|
||||||
|
_title = widget.task!.title;
|
||||||
|
_description = widget.task!.description ?? '';
|
||||||
|
_assigneeName = widget.task!.assigneeName ?? '';
|
||||||
|
_isBillable = widget.task!.isBillable;
|
||||||
|
_hourlyRate = widget.task!.hourlyRate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
left: 24,
|
||||||
|
right: 24,
|
||||||
|
top: 24,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(_isEditing ? 'Edit Task' : 'New Task', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PremiumTextField(
|
||||||
|
labelText: 'Task Title',
|
||||||
|
initialValue: _isEditing ? _title : null,
|
||||||
|
prefixIcon: const Icon(LucideIcons.checkSquare),
|
||||||
|
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
|
||||||
|
onSaved: (val) => _title = val!,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PremiumTextField(
|
||||||
|
maxLines: 3,
|
||||||
|
labelText: 'Description (Optional)',
|
||||||
|
initialValue: _isEditing ? _description : null,
|
||||||
|
prefixIcon: const Icon(LucideIcons.alignLeft),
|
||||||
|
onSaved: (val) => _description = val ?? '',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Autocomplete<User>(
|
||||||
|
initialValue: _isEditing && _assigneeName.isNotEmpty
|
||||||
|
? TextEditingValue(text: _assigneeName)
|
||||||
|
: null,
|
||||||
|
optionsBuilder: (TextEditingValue textEditingValue) async {
|
||||||
|
if (textEditingValue.text.isEmpty) {
|
||||||
|
return const Iterable<User>.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await ref.read(userSearchProvider(textEditingValue.text).future);
|
||||||
|
} catch (e) {
|
||||||
|
return const Iterable<User>.empty();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
displayStringForOption: (User option) => '${option.name} (${option.email})',
|
||||||
|
onSelected: (User selection) {
|
||||||
|
_assigneeName = selection.name;
|
||||||
|
},
|
||||||
|
optionsViewBuilder: (context, onSelected, options) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: Material(
|
||||||
|
elevation: 4,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: Colors.white,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300),
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: options.length,
|
||||||
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
final User option = options.elementAt(index);
|
||||||
|
return ListTile(
|
||||||
|
title: Text(option.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
subtitle: Text(option.email, style: const TextStyle(color: Colors.grey)),
|
||||||
|
onTap: () => onSelected(option),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
fieldViewBuilder: (context, textEditingController, focusNode, onFieldSubmitted) {
|
||||||
|
return PremiumTextField(
|
||||||
|
controller: textEditingController,
|
||||||
|
focusNode: focusNode,
|
||||||
|
labelText: 'Assignee Name (Optional)',
|
||||||
|
prefixIcon: const Icon(LucideIcons.user),
|
||||||
|
onSaved: (val) {
|
||||||
|
if (_assigneeName.isEmpty) _assigneeName = val ?? '';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Is Billable?'),
|
||||||
|
subtitle: const Text('Task time will be invoiced'),
|
||||||
|
value: _isBillable,
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
_isBillable = val;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_isBillable) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PremiumTextField(
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
labelText: 'Hourly Rate',
|
||||||
|
initialValue: _isEditing && _hourlyRate > 0 ? _hourlyRate.toString() : null,
|
||||||
|
prefixIcon: const Icon(LucideIcons.indianRupee),
|
||||||
|
validator: (val) => _isBillable && (val == null || val.isEmpty) ? 'Required for billable tasks' : null,
|
||||||
|
onSaved: (val) => _hourlyRate = double.tryParse(val ?? '0') ?? 0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
onPressed: _isLoading ? null : _submit,
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||||
|
: Text(_isEditing ? 'Save Changes' : 'Create Task', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
_formKey.currentState!.save();
|
||||||
|
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
final repo = ref.read(projectRepositoryProvider);
|
||||||
|
|
||||||
|
if (_isEditing) {
|
||||||
|
await repo.updateTask(
|
||||||
|
taskId: widget.task!.id,
|
||||||
|
title: _title,
|
||||||
|
description: _description.isNotEmpty ? _description : null,
|
||||||
|
assigneeName: _assigneeName.isNotEmpty ? _assigneeName : null,
|
||||||
|
isBillable: _isBillable,
|
||||||
|
hourlyRate: _isBillable ? _hourlyRate : null,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await repo.createTask(
|
||||||
|
projectId: widget.projectId,
|
||||||
|
title: _title,
|
||||||
|
description: _description.isNotEmpty ? _description : null,
|
||||||
|
assigneeName: _assigneeName.isNotEmpty ? _assigneeName : null,
|
||||||
|
isBillable: _isBillable,
|
||||||
|
hourlyRate: _isBillable ? _hourlyRate : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.invalidate(projectTasksProvider(widget.projectId));
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../../domain/project_task.dart';
|
||||||
|
import '../../providers/project_provider.dart';
|
||||||
|
import 'task_details_sheet.dart';
|
||||||
|
|
||||||
|
class TaskCard extends ConsumerWidget {
|
||||||
|
final ProjectTask task;
|
||||||
|
final VoidCallback onGenerateInvoice;
|
||||||
|
final VoidCallback? onEdit;
|
||||||
|
|
||||||
|
const TaskCard({
|
||||||
|
super.key,
|
||||||
|
required this.task,
|
||||||
|
required this.onGenerateInvoice,
|
||||||
|
this.onEdit,
|
||||||
|
});
|
||||||
|
|
||||||
|
Color _statusColor(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'TODO':
|
||||||
|
return Colors.blueGrey;
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return Colors.blue;
|
||||||
|
case 'REVIEW':
|
||||||
|
return Colors.orange;
|
||||||
|
case 'DONE':
|
||||||
|
return Colors.green;
|
||||||
|
default:
|
||||||
|
return Colors.grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _statusLabel(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'TODO':
|
||||||
|
return 'To Do';
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return 'In Progress';
|
||||||
|
case 'REVIEW':
|
||||||
|
return 'Review';
|
||||||
|
case 'DONE':
|
||||||
|
return 'Done';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final statusColor = _statusColor(task.status);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => TaskDetailsSheet(task: task),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onLongPress: () => _showTaskOptions(context, ref),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.grey.shade200),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.04),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Status chip + overflow menu
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: statusColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_statusLabel(task.status),
|
||||||
|
style: TextStyle(
|
||||||
|
color: statusColor,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildStatusDropdown(context, ref),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
// Task title
|
||||||
|
Text(
|
||||||
|
task.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Description preview
|
||||||
|
if (task.description != null && task.description!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
task.description!,
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade600, height: 1.4),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Bottom row: assignee + billable badge + invoice button
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (task.assigneeName != null && task.assigneeName!.isNotEmpty) ...[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.indigo.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.user, size: 12, color: Colors.indigo.shade400),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
task.assigneeName!,
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.indigo.shade600, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
],
|
||||||
|
if (task.isBillable)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.indianRupee, size: 11, color: Colors.green.shade600),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Text(
|
||||||
|
'Billable',
|
||||||
|
style: TextStyle(color: Colors.green.shade700, fontSize: 11, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (task.isBillable && task.status == 'DONE')
|
||||||
|
SizedBox(
|
||||||
|
height: 28,
|
||||||
|
child: TextButton.icon(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
backgroundColor: Colors.green.withValues(alpha: 0.1),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
icon: Icon(LucideIcons.receipt, size: 13, color: Colors.green.shade700),
|
||||||
|
label: Text('Invoice', style: TextStyle(fontSize: 11, color: Colors.green.shade700, fontWeight: FontWeight.w600)),
|
||||||
|
onPressed: onGenerateInvoice,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showTaskOptions(BuildContext context, WidgetRef ref) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (ctx) => Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(top: 12),
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(task.title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(LucideIcons.eye),
|
||||||
|
title: const Text('View Details'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => TaskDetailsSheet(task: task),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (onEdit != null)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(LucideIcons.edit),
|
||||||
|
title: const Text('Edit Task'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
onEdit!();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(LucideIcons.trash2, color: Colors.red),
|
||||||
|
title: const Text('Delete Task', style: TextStyle(color: Colors.red)),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (c) => AlertDialog(
|
||||||
|
title: const Text('Delete Task'),
|
||||||
|
content: Text('Are you sure you want to delete "${task.title}"?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(c, true),
|
||||||
|
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirm == true) {
|
||||||
|
try {
|
||||||
|
await ref.read(projectRepositoryProvider).deleteTask(task.id);
|
||||||
|
ref.invalidate(projectTasksProvider(task.projectId));
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusDropdown(BuildContext context, WidgetRef ref) {
|
||||||
|
return PopupMenuButton<String>(
|
||||||
|
initialValue: task.status,
|
||||||
|
tooltip: 'Change Status',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
child: Icon(LucideIcons.moreVertical, size: 18, color: Colors.grey.shade500),
|
||||||
|
onSelected: (newStatus) async {
|
||||||
|
if (newStatus != task.status) {
|
||||||
|
try {
|
||||||
|
await ref.read(projectRepositoryProvider).updateTask(
|
||||||
|
taskId: task.id,
|
||||||
|
status: newStatus,
|
||||||
|
);
|
||||||
|
ref.invalidate(projectTasksProvider(task.projectId));
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to update status: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
_buildStatusMenuItem('TODO', 'To Do', Colors.blueGrey),
|
||||||
|
_buildStatusMenuItem('IN_PROGRESS', 'In Progress', Colors.blue),
|
||||||
|
_buildStatusMenuItem('REVIEW', 'Review', Colors.orange),
|
||||||
|
_buildStatusMenuItem('DONE', 'Done', Colors.green),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PopupMenuItem<String> _buildStatusMenuItem(String value, String label, Color color) {
|
||||||
|
return PopupMenuItem<String>(
|
||||||
|
value: value,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(label),
|
||||||
|
if (task.status == value) ...[
|
||||||
|
const Spacer(),
|
||||||
|
const Icon(LucideIcons.check, size: 16, color: Colors.green),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import '../../domain/project_task.dart';
|
||||||
|
import '../../providers/project_provider.dart';
|
||||||
|
import '../../../transactions/presentation/widgets/attachment_gallery_screen.dart';
|
||||||
|
|
||||||
|
class TaskDetailsSheet extends ConsumerStatefulWidget {
|
||||||
|
final ProjectTask task;
|
||||||
|
|
||||||
|
const TaskDetailsSheet({super.key, required this.task});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<TaskDetailsSheet> createState() => _TaskDetailsSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
||||||
|
final TextEditingController _commentController = TextEditingController();
|
||||||
|
final List<String> _imagesBase64 = [];
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
|
||||||
|
Future<void> _pickImage() async {
|
||||||
|
if (_imagesBase64.length >= 3) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 3 images allowed')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final ImagePicker picker = ImagePicker();
|
||||||
|
final XFile? image = await picker.pickImage(source: ImageSource.gallery, imageQuality: 50);
|
||||||
|
if (image != null) {
|
||||||
|
final bytes = await image.readAsBytes();
|
||||||
|
setState(() {
|
||||||
|
_imagesBase64.add(base64Encode(bytes));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitComment() async {
|
||||||
|
if (_commentController.text.trim().isEmpty && _imagesBase64.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() => _isSubmitting = true);
|
||||||
|
try {
|
||||||
|
final repo = ref.read(projectRepositoryProvider);
|
||||||
|
await repo.addComment(
|
||||||
|
taskId: widget.task.id,
|
||||||
|
content: _commentController.text.trim(),
|
||||||
|
imagesBase64: _imagesBase64,
|
||||||
|
);
|
||||||
|
_commentController.clear();
|
||||||
|
setState(() {
|
||||||
|
_imagesBase64.clear();
|
||||||
|
});
|
||||||
|
ref.invalidate(projectTaskCommentsProvider(widget.task.id));
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error adding comment: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isSubmitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final commentsAsync = ref.watch(projectTaskCommentsProvider(widget.task.id));
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.9,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildHeader(context),
|
||||||
|
Expanded(
|
||||||
|
child: commentsAsync.when(
|
||||||
|
data: (comments) {
|
||||||
|
if (comments.isEmpty) return const Center(child: Text('No comments yet.'));
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: comments.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final c = comments[index];
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
color: Colors.grey[100],
|
||||||
|
elevation: 0,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(c.content, style: const TextStyle(fontSize: 14)),
|
||||||
|
if (c.images.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: c.images.asMap().entries.map((entry) {
|
||||||
|
final imgIndex = entry.key;
|
||||||
|
final img = entry.value;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
final allImages = c.images.map((i) => MemoryImage(base64Decode(i)) as ImageProvider).toList();
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => AttachmentGalleryScreen(
|
||||||
|
images: allImages,
|
||||||
|
initialIndex: imgIndex,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.memory(
|
||||||
|
base64Decode(img),
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildCommentInput(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: Colors.grey.shade300))),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(widget.task.title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
if (widget.task.description != null && widget.task.description!.isNotEmpty)
|
||||||
|
Text(widget.task.description!, style: const TextStyle(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCommentInput() {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: 16,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_imagesBase64.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8.0),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: _imagesBase64.asMap().entries.map((entry) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.memory(
|
||||||
|
base64Decode(entry.value),
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_imagesBase64.removeAt(entry.key);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black54,
|
||||||
|
child: const Icon(LucideIcons.x, size: 16, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.image),
|
||||||
|
onPressed: _pickImage,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _commentController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Add a comment...',
|
||||||
|
border: InputBorder.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_isSubmitting
|
||||||
|
? const Padding(padding: EdgeInsets.all(12.0), child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(LucideIcons.send, color: Colors.blue),
|
||||||
|
onPressed: _submitComment,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class ProjectModeNotifier extends Notifier<bool> {
|
||||||
|
@override
|
||||||
|
bool build() {
|
||||||
|
_loadState();
|
||||||
|
return false; // Default until loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadState() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
state = prefs.getBool('is_project_mode') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
state = !state;
|
||||||
|
await prefs.setBool('is_project_mode', state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final projectModeProvider = NotifierProvider<ProjectModeNotifier, bool>(() {
|
||||||
|
return ProjectModeNotifier();
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import '../../../core/network/dio_client.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../auth/providers/auth_provider.dart';
|
||||||
|
import '../data/project_repository.dart';
|
||||||
|
import '../domain/project.dart';
|
||||||
|
import '../domain/project_task.dart';
|
||||||
|
import '../domain/project_task_comment.dart';
|
||||||
|
|
||||||
|
final projectRepositoryProvider = Provider<ProjectRepository>((ref) {
|
||||||
|
return ProjectRepository(DioClient().dio);
|
||||||
|
});
|
||||||
|
|
||||||
|
final projectsProvider = FutureProvider<List<Project>>((ref) async {
|
||||||
|
final repo = ref.watch(projectRepositoryProvider);
|
||||||
|
return repo.getProjects();
|
||||||
|
});
|
||||||
|
|
||||||
|
final projectTasksProvider = FutureProvider.family<List<ProjectTask>, int>((ref, projectId) async {
|
||||||
|
final repo = ref.watch(projectRepositoryProvider);
|
||||||
|
return repo.getTasks(projectId);
|
||||||
|
});
|
||||||
|
|
||||||
|
final projectTaskCommentsProvider = FutureProvider.family<List<ProjectTaskComment>, int>((ref, taskId) async {
|
||||||
|
final repo = ref.watch(projectRepositoryProvider);
|
||||||
|
return repo.getComments(taskId);
|
||||||
|
});
|
||||||
18
kifi-app/lib/features/projects/providers/user_provider.dart
Normal file
18
kifi-app/lib/features/projects/providers/user_provider.dart
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../../core/network/dio_client.dart';
|
||||||
|
import '../../../../core/domain/user.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
final userSearchProvider = FutureProvider.family<List<User>, String>((ref, query) async {
|
||||||
|
if (query.isEmpty) return [];
|
||||||
|
|
||||||
|
final dio = DioClient().dio;
|
||||||
|
final response = await dio.get('/users/search', queryParameters: {'q': query});
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final List data = response.data;
|
||||||
|
return data.map((json) => User.fromJson(json)).toList();
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to search users');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -11,12 +11,16 @@ import '../domain/invoice.dart';
|
|||||||
import '../providers/customers_provider.dart';
|
import '../providers/customers_provider.dart';
|
||||||
import '../domain/customer.dart';
|
import '../domain/customer.dart';
|
||||||
import '../../inventory/providers/products_provider.dart';
|
import '../../inventory/providers/products_provider.dart';
|
||||||
|
import '../../projects/providers/project_mode_provider.dart';
|
||||||
import '../../inventory/domain/product.dart';
|
import '../../inventory/domain/product.dart';
|
||||||
import 'add_customer_sheet.dart';
|
import 'add_customer_sheet.dart';
|
||||||
import '../../transactions/providers/providers.dart';
|
import '../../transactions/providers/providers.dart';
|
||||||
|
|
||||||
class InvoiceBuilderScreen extends ConsumerStatefulWidget {
|
class InvoiceBuilderScreen extends ConsumerStatefulWidget {
|
||||||
const InvoiceBuilderScreen({super.key});
|
final List<InvoiceItem>? initialItems;
|
||||||
|
final int? initialCustomerId;
|
||||||
|
|
||||||
|
const InvoiceBuilderScreen({super.key, this.initialItems, this.initialCustomerId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<InvoiceBuilderScreen> createState() => _InvoiceBuilderScreenState();
|
ConsumerState<InvoiceBuilderScreen> createState() => _InvoiceBuilderScreenState();
|
||||||
@@ -27,6 +31,29 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
|
|||||||
Customer? _selectedCustomer;
|
Customer? _selectedCustomer;
|
||||||
final List<InvoiceItem> _items = [];
|
final List<InvoiceItem> _items = [];
|
||||||
bool _isEmi = false;
|
bool _isEmi = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.initialItems != null) {
|
||||||
|
_items.addAll(widget.initialItems!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadInitialCustomer() {
|
||||||
|
if (widget.initialCustomerId != null && _selectedCustomer == null) {
|
||||||
|
final customers = ref.read(customersProvider).value;
|
||||||
|
if (customers != null) {
|
||||||
|
final cust = customers.where((c) => c.id == widget.initialCustomerId).firstOrNull;
|
||||||
|
if (cust != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCustomer = cust;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _emiCycle = 'MONTHLY';
|
String _emiCycle = 'MONTHLY';
|
||||||
final TextEditingController _emiAmountCtrl = TextEditingController();
|
final TextEditingController _emiAmountCtrl = TextEditingController();
|
||||||
final TextEditingController _invoiceNumberCtrl = TextEditingController(text: 'INV-${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}');
|
final TextEditingController _invoiceNumberCtrl = TextEditingController(text: 'INV-${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}');
|
||||||
@@ -104,6 +131,7 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showAddItemDialog() {
|
void _showAddItemDialog() {
|
||||||
|
final isProjectMode = ref.read(projectModeProvider);
|
||||||
Product? selectedProduct;
|
Product? selectedProduct;
|
||||||
final TextEditingController descCtrl = TextEditingController();
|
final TextEditingController descCtrl = TextEditingController();
|
||||||
final TextEditingController qtyCtrl = TextEditingController(text: '1');
|
final TextEditingController qtyCtrl = TextEditingController(text: '1');
|
||||||
@@ -126,7 +154,7 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Consumer(
|
if (!isProjectMode) Consumer(
|
||||||
builder: (context, dialogRef, _) {
|
builder: (context, dialogRef, _) {
|
||||||
final productsState = dialogRef.watch(productsProvider);
|
final productsState = dialogRef.watch(productsProvider);
|
||||||
return productsState.when(
|
return productsState.when(
|
||||||
@@ -222,14 +250,16 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
if (!isProjectMode) ...[
|
||||||
Row(
|
const SizedBox(height: 12),
|
||||||
children: [
|
Row(
|
||||||
Expanded(child: PremiumTextField(controller: makingCtrl, labelText: 'Making Chg', keyboardType: TextInputType.number)),
|
children: [
|
||||||
const SizedBox(width: 8),
|
Expanded(child: PremiumTextField(controller: makingCtrl, labelText: 'Making Chg', keyboardType: TextInputType.number)),
|
||||||
Expanded(child: PremiumTextField(controller: otherCtrl, labelText: 'Other Chg', keyboardType: TextInputType.number)),
|
const SizedBox(width: 8),
|
||||||
],
|
Expanded(child: PremiumTextField(controller: otherCtrl, labelText: 'Other Chg', keyboardType: TextInputType.number)),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -332,6 +362,7 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
_loadInitialCustomer();
|
||||||
final customersState = ref.watch(customersProvider);
|
final customersState = ref.watch(customersProvider);
|
||||||
final formatCurrency = NumberFormat.currency(symbol: '₹');
|
final formatCurrency = NumberFormat.currency(symbol: '₹');
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
|||||||
final formatDate = DateFormat('MMM dd, yyyy');
|
final formatDate = DateFormat('MMM dd, yyyy');
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.grey[50],
|
backgroundColor: Colors.grey[100],
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Invoices'),
|
title: const Text('Invoices'),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
|||||||
@@ -20,25 +20,30 @@ class _VendorsListScreenState extends ConsumerState<VendorsListScreen> {
|
|||||||
final darkTheme = Theme.of(context).brightness == Brightness.dark;
|
final darkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.grey[100],
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Vendors'),
|
title: const Text('Vendors', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0),
|
color: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search vendors...',
|
hintText: 'Search vendors...',
|
||||||
prefixIcon: const Icon(LucideIcons.search),
|
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: darkTheme ? Colors.grey[900] : Colors.grey[100],
|
fillColor: Colors.grey[100],
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
),
|
),
|
||||||
onChanged: (val) => setState(() => _searchQuery = val),
|
onChanged: (val) => setState(() => _searchQuery = val),
|
||||||
),
|
),
|
||||||
|
|||||||
21
scratch/fix_imports.py
Normal file
21
scratch/fix_imports.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
if "add_project_sheet.dart" not in content:
|
||||||
|
content = content.replace("import 'widgets/task_card.dart';", "import 'widgets/task_card.dart';\nimport 'widgets/add_project_sheet.dart';")
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
if "project.dart" not in content:
|
||||||
|
content = content.replace("import 'package:flutter/material.dart';", "import 'package:flutter/material.dart';\nimport '../../domain/project.dart';")
|
||||||
|
|
||||||
|
content = content.replace("await repo.updateProject(\n id: widget.project!.id,", "await repo.updateProject(\n projectId: widget.project!.id,")
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
27
scratch/fix_task_sheet.py
Normal file
27
scratch/fix_task_sheet.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
content = content.replace("import '../../../../core/widgets/bottom_sheet_container.dart';", "")
|
||||||
|
content = content.replace(" return BottomSheetContainer(\n title: 'New Task',\n child: Form(",
|
||||||
|
" return Container(\n padding: EdgeInsets.only(\n bottom: MediaQuery.of(context).viewInsets.bottom,\n left: 24,\n right: 24,\n top: 24,\n ),\n decoration: const BoxDecoration(\n color: Colors.white,\n borderRadius: BorderRadius.vertical(top: Radius.circular(24)),\n ),\n child: SafeArea(\n child: Form(")
|
||||||
|
|
||||||
|
header = """ children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('New Task', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PremiumTextField("""
|
||||||
|
|
||||||
|
content = content.replace(" children: [\n PremiumTextField(", header)
|
||||||
|
|
||||||
|
content = content.replace(" );\n }\n", " ),\n );\n }\n") # Adding missing parenthesis for SafeArea and Container
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
39
scratch/patch_add_project.py
Normal file
39
scratch/patch_add_project.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Add import
|
||||||
|
content = content.replace(
|
||||||
|
'import \'../../../sales/providers/customers_provider.dart\';',
|
||||||
|
'import \'../../../sales/providers/customers_provider.dart\';\nimport \'../../../sales/domain/customer.dart\';'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fix SmartSearchDropdown
|
||||||
|
old_dropdown = """ SmartSearchDropdown(
|
||||||
|
label: 'Client (Optional)',
|
||||||
|
items: customersState.value!.map((c) => DropdownMenuItem(value: c.id.toString(), child: Text(c.name))).toList(),
|
||||||
|
value: _selectedCustomerId?.toString(),
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCustomerId = val != null ? int.parse(val) : null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),"""
|
||||||
|
|
||||||
|
new_dropdown = """ SmartSearchDropdown<Customer>(
|
||||||
|
labelText: 'Client (Optional)',
|
||||||
|
items: customersState.value!,
|
||||||
|
itemAsString: (c) => c.name,
|
||||||
|
value: customersState.value!.where((c) => c.id == _selectedCustomerId).firstOrNull,
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCustomerId = val?.id;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),"""
|
||||||
|
|
||||||
|
content = content.replace(old_dropdown, new_dropdown)
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
41
scratch/patch_autocomplete.py
Normal file
41
scratch/patch_autocomplete.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
old_autocomplete = r""" fieldViewBuilder: \(context, textEditingController, focusNode, onFieldSubmitted\) \{
|
||||||
|
return PremiumTextField\("""
|
||||||
|
|
||||||
|
new_autocomplete = """ optionsViewBuilder: (context, onSelected, options) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: Material(
|
||||||
|
elevation: 4,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: Colors.white,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300),
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: options.length,
|
||||||
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
final User option = options.elementAt(index);
|
||||||
|
return ListTile(
|
||||||
|
title: Text(option.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
subtitle: Text(option.email, style: const TextStyle(color: Colors.grey)),
|
||||||
|
onTap: () => onSelected(option),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
fieldViewBuilder: (context, textEditingController, focusNode, onFieldSubmitted) {
|
||||||
|
return PremiumTextField("""
|
||||||
|
|
||||||
|
content = re.sub(old_autocomplete, new_autocomplete, content)
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
38
scratch/patch_board.py
Normal file
38
scratch/patch_board.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Add button for "To Do" column
|
||||||
|
content = content.replace(
|
||||||
|
'_buildColumn(\'To Do\', todo),',
|
||||||
|
'_buildColumn(\'To Do\', todo, showAdd: true),'
|
||||||
|
)
|
||||||
|
content = content.replace(
|
||||||
|
'Widget _buildColumn(String title, List tasks) {',
|
||||||
|
'Widget _buildColumn(String title, List tasks, {bool showAdd = false}) {'
|
||||||
|
)
|
||||||
|
|
||||||
|
add_button_code = """
|
||||||
|
if (showAdd)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: TextButton.icon(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Add Task'),
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => AddTaskSheet(projectId: widget.project.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
"""
|
||||||
|
content = content.replace(' const SizedBox(height: 16),\n ],\n ),\n );\n }', add_button_code + ' ],\n ),\n );\n }')
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
44
scratch/patch_board2.py
Normal file
44
scratch/patch_board2.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Replace old Card with TaskCard
|
||||||
|
old_card = """ ...tasks.map((t) => Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(t.title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
if (t.isBillable) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(color: Colors.green[50], borderRadius: BorderRadius.circular(4)),
|
||||||
|
child: const Text('Billable', style: TextStyle(color: Colors.green, fontSize: 12)),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)),"""
|
||||||
|
|
||||||
|
new_card = """ ...tasks.map((t) => TaskCard(
|
||||||
|
task: t,
|
||||||
|
onGenerateInvoice: () {
|
||||||
|
// TODO: Navigate to AddInvoiceScreen with task data
|
||||||
|
},
|
||||||
|
)),"""
|
||||||
|
|
||||||
|
content = content.replace(old_card, new_card)
|
||||||
|
|
||||||
|
# Add import
|
||||||
|
content = content.replace(
|
||||||
|
'import \'widgets/add_task_sheet.dart\';',
|
||||||
|
'import \'widgets/add_task_sheet.dart\';\nimport \'widgets/task_card.dart\';'
|
||||||
|
)
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
40
scratch/patch_board_screen.py
Normal file
40
scratch/patch_board_screen.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Add edit button to AppBar
|
||||||
|
old_app_bar = r""" appBar: AppBar\(
|
||||||
|
title: Text\(widget\.project\.name\),
|
||||||
|
backgroundColor: Colors\.white,
|
||||||
|
foregroundColor: Colors\.black,
|
||||||
|
elevation: 0,
|
||||||
|
\),"""
|
||||||
|
|
||||||
|
new_app_bar = """ appBar: AppBar(
|
||||||
|
title: Text(widget.project.name),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
elevation: 0,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.info_outline),
|
||||||
|
tooltip: 'Project Details',
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => AddProjectSheet(project: widget.project),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),"""
|
||||||
|
|
||||||
|
content = re.sub(old_app_bar, new_app_bar, content)
|
||||||
|
if "AddProjectSheet" not in content:
|
||||||
|
content = content.replace("import 'widgets/add_task_sheet.dart';", "import 'widgets/add_task_sheet.dart';\nimport 'widgets/add_project_sheet.dart';")
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
46
scratch/patch_builder.py
Normal file
46
scratch/patch_builder.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Add initialItems to constructor
|
||||||
|
content = content.replace(
|
||||||
|
'const InvoiceBuilderScreen({super.key});',
|
||||||
|
'final List<InvoiceItem>? initialItems;\n final int? initialCustomerId;\n\n const InvoiceBuilderScreen({super.key, this.initialItems, this.initialCustomerId});'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Populate initial items
|
||||||
|
init_state = """
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.initialItems != null) {
|
||||||
|
_items.addAll(widget.initialItems!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadInitialCustomer() {
|
||||||
|
if (widget.initialCustomerId != null && _selectedCustomer == null) {
|
||||||
|
final customers = ref.read(customersProvider).value;
|
||||||
|
if (customers != null) {
|
||||||
|
final cust = customers.where((c) => c.id == widget.initialCustomerId).firstOrNull;
|
||||||
|
if (cust != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCustomer = cust;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
content = content.replace(' bool _isEmi = false;', ' bool _isEmi = false;\n' + init_state)
|
||||||
|
|
||||||
|
# Call _loadInitialCustomer in build
|
||||||
|
content = content.replace(
|
||||||
|
'Widget build(BuildContext context) {',
|
||||||
|
'Widget build(BuildContext context) {\n _loadInitialCustomer();'
|
||||||
|
)
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
103
scratch/patch_dashboard.py
Normal file
103
scratch/patch_dashboard.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
home_start_idx = content.find("RefreshIndicator(\n onRefresh: _onRefresh,")
|
||||||
|
home_end_idx = content.find("// ---------------- PROJECTS TAB ----------------")
|
||||||
|
home_widget = content[home_start_idx:home_end_idx].strip()
|
||||||
|
if home_widget.endswith(","):
|
||||||
|
home_widget = home_widget[:-1]
|
||||||
|
|
||||||
|
stats_start_idx = content.find("StatisticsTab(")
|
||||||
|
stats_end_idx = content.find("// ---------------- WALLETS TAB ----------------")
|
||||||
|
stats_widget = content[stats_start_idx:stats_end_idx].strip()
|
||||||
|
if stats_widget.endswith(","):
|
||||||
|
stats_widget = stats_widget[:-1]
|
||||||
|
|
||||||
|
# We need to replace everything from `return IndexedStack(` to the end of bottom navbar
|
||||||
|
old_start_idx = content.find("return IndexedStack(")
|
||||||
|
old_end_idx = content.find(" );\n }\n\n Widget _buildSummaryCard")
|
||||||
|
|
||||||
|
if old_start_idx == -1 or old_end_idx == -1:
|
||||||
|
print("Could not find bounds")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
new_code = """final List<Widget> screens = [];
|
||||||
|
|
||||||
|
screens.add(""" + home_widget + """);
|
||||||
|
|
||||||
|
if (isProjectMode) {
|
||||||
|
screens.add(const ProjectsScreen());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBusinessMode) {
|
||||||
|
screens.add(const BusinessHubScreen());
|
||||||
|
}
|
||||||
|
|
||||||
|
screens.add(""" + stats_widget + """);
|
||||||
|
|
||||||
|
final addIndex = screens.length;
|
||||||
|
screens.add(const SizedBox.shrink()); // Placeholder for Add
|
||||||
|
|
||||||
|
screens.add(const AccountsScreen());
|
||||||
|
|
||||||
|
screens.add(const BudgetScreen());
|
||||||
|
|
||||||
|
int safeIndex = _currentIndex;
|
||||||
|
if (safeIndex >= screens.length) safeIndex = screens.length - 1;
|
||||||
|
|
||||||
|
return IndexedStack(
|
||||||
|
index: safeIndex,
|
||||||
|
children: screens,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
bottomNavigationBar: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final isProjectMode = ref.watch(projectModeProvider);
|
||||||
|
final isBusinessMode = ref.watch(businessModeProvider);
|
||||||
|
final List<BottomNavigationBarItem> navItems = [];
|
||||||
|
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'));
|
||||||
|
if (isProjectMode) navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.trello), label: 'Projects'));
|
||||||
|
if (isBusinessMode) navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.briefcase), label: 'Business'));
|
||||||
|
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'));
|
||||||
|
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'));
|
||||||
|
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'));
|
||||||
|
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'));
|
||||||
|
|
||||||
|
int safeIndex = _currentIndex;
|
||||||
|
if (safeIndex >= navItems.length) safeIndex = navItems.length - 1;
|
||||||
|
|
||||||
|
return BottomNavigationBar(
|
||||||
|
currentIndex: safeIndex,
|
||||||
|
type: BottomNavigationBarType.fixed,
|
||||||
|
onTap: (index) {
|
||||||
|
int addIdx = 1;
|
||||||
|
if (isProjectMode) addIdx++;
|
||||||
|
if (isBusinessMode) addIdx++;
|
||||||
|
addIdx++; // For Stats
|
||||||
|
|
||||||
|
if (index == addIdx) {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen()));
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_currentIndex = index;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedItemColor: Theme.of(context).colorScheme.primary,
|
||||||
|
unselectedItemColor: Colors.grey,
|
||||||
|
showSelectedLabels: true,
|
||||||
|
showUnselectedLabels: true,
|
||||||
|
items: navItems,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
);"""
|
||||||
|
|
||||||
|
new_content = content[:old_start_idx] + "return Builder(builder: (context) {\n" + new_code + content[old_end_idx+6:]
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'w') as f:
|
||||||
|
f.write(new_content)
|
||||||
|
print("Dashboard screen patched")
|
||||||
145
scratch/patch_dashboard2.py
Normal file
145
scratch/patch_dashboard2.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Add imports
|
||||||
|
imports_added = False
|
||||||
|
if "import '../../auth/providers/auth_provider.dart';" not in content:
|
||||||
|
content = content.replace(
|
||||||
|
"import '../../business/providers/indian_states_provider.dart';",
|
||||||
|
"import '../../business/providers/indian_states_provider.dart';\nimport '../../auth/providers/auth_provider.dart';\nimport '../../projects/presentation/projects_screen.dart';"
|
||||||
|
)
|
||||||
|
imports_added = True
|
||||||
|
|
||||||
|
# Add isProjectMode
|
||||||
|
if "final isProjectMode = ref.watch(projectModeProvider);" not in content:
|
||||||
|
content = content.replace(
|
||||||
|
"final isBusinessMode = ref.watch(businessModeProvider);",
|
||||||
|
"final isProjectMode = ref.watch(projectModeProvider);\n final isBusinessMode = ref.watch(businessModeProvider);"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fix Title logic
|
||||||
|
old_title_logic = """ String title;
|
||||||
|
if (_currentIndex == 0) {
|
||||||
|
title = 'Dashboard';
|
||||||
|
} else if (isBusinessMode) {
|
||||||
|
if (_currentIndex == 1) title = 'Business Hub';
|
||||||
|
else if (_currentIndex == 2) title = 'Statistics';
|
||||||
|
else if (_currentIndex == 3) title = 'My Accounts';
|
||||||
|
else title = 'Budgets';
|
||||||
|
} else {
|
||||||
|
if (_currentIndex == 1) title = 'Statistics';
|
||||||
|
else if (_currentIndex == 2) title = 'My Accounts';
|
||||||
|
else title = 'Budgets';
|
||||||
|
}"""
|
||||||
|
|
||||||
|
new_title_logic = """ String title = 'Dashboard';
|
||||||
|
int logicalIndex = _currentIndex;
|
||||||
|
if (logicalIndex == 0) title = 'Dashboard';
|
||||||
|
else {
|
||||||
|
if (isProjectMode) {
|
||||||
|
if (logicalIndex == 1) title = 'Projects';
|
||||||
|
logicalIndex--;
|
||||||
|
}
|
||||||
|
if (isBusinessMode) {
|
||||||
|
if (logicalIndex == 1) title = 'Business Hub';
|
||||||
|
logicalIndex--;
|
||||||
|
}
|
||||||
|
if (logicalIndex == 1) title = 'Statistics';
|
||||||
|
else if (logicalIndex == 2) title = 'My Accounts';
|
||||||
|
else if (logicalIndex == 3) title = 'Budgets';
|
||||||
|
}"""
|
||||||
|
content = content.replace(old_title_logic, new_title_logic)
|
||||||
|
|
||||||
|
home_start_idx = content.find("RefreshIndicator(\n onRefresh: _onRefresh,")
|
||||||
|
home_end_idx = content.find("// ---------------- BUSINESS TAB ----------------")
|
||||||
|
home_widget = content[home_start_idx:home_end_idx].strip()
|
||||||
|
if home_widget.endswith(","):
|
||||||
|
home_widget = home_widget[:-1]
|
||||||
|
|
||||||
|
stats_start_idx = content.find("StatisticsTab(")
|
||||||
|
stats_end_idx = content.find("// ---------------- WALLETS TAB ----------------")
|
||||||
|
stats_widget = content[stats_start_idx:stats_end_idx].strip()
|
||||||
|
if stats_widget.endswith(","):
|
||||||
|
stats_widget = stats_widget[:-1]
|
||||||
|
|
||||||
|
old_stack_start_idx = content.find("return IndexedStack(")
|
||||||
|
old_bottom_end_idx = content.find(" );\n }\n\n Widget _buildSummaryCard")
|
||||||
|
|
||||||
|
new_body_and_bottom = """ final List<Widget> screens = [];
|
||||||
|
|
||||||
|
screens.add(""" + home_widget + """);
|
||||||
|
|
||||||
|
if (isProjectMode) {
|
||||||
|
screens.add(const ProjectsScreen());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBusinessMode) {
|
||||||
|
screens.add(const BusinessHubScreen());
|
||||||
|
}
|
||||||
|
|
||||||
|
screens.add(""" + stats_widget + """);
|
||||||
|
screens.add(const AccountsScreen());
|
||||||
|
screens.add(const BudgetScreen());
|
||||||
|
|
||||||
|
int safeIndex = _currentIndex;
|
||||||
|
if (safeIndex >= screens.length) safeIndex = screens.length - 1;
|
||||||
|
|
||||||
|
return IndexedStack(
|
||||||
|
index: safeIndex,
|
||||||
|
children: screens,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
bottomNavigationBar: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final isProjectMode = ref.watch(projectModeProvider);
|
||||||
|
final isBusinessMode = ref.watch(businessModeProvider);
|
||||||
|
final List<BottomNavigationBarItem> navItems = [];
|
||||||
|
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'));
|
||||||
|
if (isProjectMode) navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.trello), label: 'Projects'));
|
||||||
|
if (isBusinessMode) navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.briefcase), label: 'Business'));
|
||||||
|
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'));
|
||||||
|
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'));
|
||||||
|
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'));
|
||||||
|
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'));
|
||||||
|
|
||||||
|
int safeIndex = _currentIndex;
|
||||||
|
// Adjust _currentIndex for the display of bottom nav bar because of 'Add' button
|
||||||
|
int addIdx = 1;
|
||||||
|
if (isProjectMode) addIdx++;
|
||||||
|
if (isBusinessMode) addIdx++;
|
||||||
|
addIdx++; // For Stats
|
||||||
|
|
||||||
|
int displayIndex = _currentIndex;
|
||||||
|
if (_currentIndex >= addIdx) displayIndex++;
|
||||||
|
|
||||||
|
if (displayIndex >= navItems.length) displayIndex = navItems.length - 1;
|
||||||
|
|
||||||
|
return BottomNavigationBar(
|
||||||
|
currentIndex: displayIndex,
|
||||||
|
type: BottomNavigationBarType.fixed,
|
||||||
|
onTap: (index) {
|
||||||
|
if (index == addIdx) {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen()));
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_currentIndex = index > addIdx ? index - 1 : index;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedItemColor: Theme.of(context).colorScheme.primary,
|
||||||
|
unselectedItemColor: Colors.grey,
|
||||||
|
showSelectedLabels: true,
|
||||||
|
showUnselectedLabels: true,
|
||||||
|
items: navItems,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),"""
|
||||||
|
|
||||||
|
content = content[:old_stack_start_idx] + new_body_and_bottom + content[old_bottom_end_idx:]
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
print("Dashboard screen patched fully")
|
||||||
19
scratch/patch_dashboard_projects.py
Normal file
19
scratch/patch_dashboard_projects.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
content = content.replace("import '../../projects/presentation/projects_screen.dart';", "import '../../projects/presentation/project_hub_screen.dart';\nimport '../../projects/presentation/projects_screen.dart';")
|
||||||
|
|
||||||
|
old_projects_mode = r""" if \(isProjectMode\) {
|
||||||
|
screens\.add\(const ProjectsScreen\(\)\);
|
||||||
|
}"""
|
||||||
|
|
||||||
|
new_projects_mode = """ if (isProjectMode) {
|
||||||
|
screens.add(const ProjectHubScreen());
|
||||||
|
}"""
|
||||||
|
|
||||||
|
content = re.sub(old_projects_mode, new_projects_mode, content)
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
9
scratch/patch_dashboard_tabs.py
Normal file
9
scratch/patch_dashboard_tabs.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# We need to replace the `IndexedStack` and `BottomNavigationBar` with a simpler mapping
|
||||||
|
# But wait, building lists inside `build` is better.
|
||||||
|
# However, the `Scaffold` body contains the `IndexedStack`.
|
||||||
|
|
||||||
52
scratch/patch_edit_project.py
Normal file
52
scratch/patch_edit_project.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Make AddProjectSheet take a project
|
||||||
|
content = content.replace("class AddProjectSheet extends ConsumerStatefulWidget {\n const AddProjectSheet({super.key});", "class AddProjectSheet extends ConsumerStatefulWidget {\n final Project? project;\n const AddProjectSheet({super.key, this.project});")
|
||||||
|
|
||||||
|
content = content.replace(" String _name = '';\n String _description = '';\n int? _selectedCustomerId;\n double _budget = 0;", """ String _name = '';
|
||||||
|
String _description = '';
|
||||||
|
int? _selectedCustomerId;
|
||||||
|
double _budget = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.project != null) {
|
||||||
|
_name = widget.project!.name;
|
||||||
|
_description = widget.project!.description ?? '';
|
||||||
|
_selectedCustomerId = widget.project!.customerId;
|
||||||
|
_budget = widget.project!.budget ?? 0;
|
||||||
|
}
|
||||||
|
}""")
|
||||||
|
|
||||||
|
content = content.replace("labelText: 'Project Name',\n prefixIcon: const Icon(LucideIcons.folder),", "labelText: 'Project Name',\n initialValue: _name,\n prefixIcon: const Icon(LucideIcons.folder),")
|
||||||
|
content = content.replace("labelText: 'Description (Optional)',\n prefixIcon: const Icon(LucideIcons.alignLeft),", "labelText: 'Description (Optional)',\n initialValue: _description,\n prefixIcon: const Icon(LucideIcons.alignLeft),")
|
||||||
|
content = content.replace("labelText: 'Budget (Optional)',\n prefixIcon: const Icon(LucideIcons.indianRupee),", "labelText: 'Budget (Optional)',\n initialValue: _budget > 0 ? _budget.toString() : '',\n prefixIcon: const Icon(LucideIcons.indianRupee),")
|
||||||
|
|
||||||
|
content = content.replace("await repo.createProject(\n name: _name,\n description: _description.isNotEmpty ? _description : null,\n customerId: _selectedCustomerId,\n budget: _budget,\n );", """if (widget.project != null) {
|
||||||
|
await repo.updateProject(
|
||||||
|
id: widget.project!.id,
|
||||||
|
name: _name,
|
||||||
|
description: _description.isNotEmpty ? _description : null,
|
||||||
|
customerId: _selectedCustomerId,
|
||||||
|
budget: _budget,
|
||||||
|
status: widget.project!.status,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await repo.createProject(
|
||||||
|
name: _name,
|
||||||
|
description: _description.isNotEmpty ? _description : null,
|
||||||
|
customerId: _selectedCustomerId,
|
||||||
|
budget: _budget,
|
||||||
|
);
|
||||||
|
}""")
|
||||||
|
|
||||||
|
content = content.replace("child: _isLoading ? const CircularProgressIndicator(color: Colors.white) : const Text('Create Project', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),", "child: _isLoading ? const CircularProgressIndicator(color: Colors.white) : Text(widget.project == null ? 'Create Project' : 'Save Changes', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),")
|
||||||
|
|
||||||
|
content = content.replace("const Text('New Project', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),", "Text(widget.project == null ? 'New Project' : 'Edit Project', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),")
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
39
scratch/patch_hub_inkwell.py
Normal file
39
scratch/patch_hub_inkwell.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/project_hub_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
old_banner = r""" Container\(
|
||||||
|
padding: const EdgeInsets\.all\(16\),
|
||||||
|
decoration: BoxDecoration\(
|
||||||
|
color: Theme\.of\(context\)\.colorScheme\.primary\.withOpacity\(0\.1\),
|
||||||
|
borderRadius: BorderRadius\.circular\(16\),
|
||||||
|
border: Border\.all\(color: Theme\.of\(context\)\.colorScheme\.primary\.withOpacity\(0\.2\)\),
|
||||||
|
\),"""
|
||||||
|
|
||||||
|
new_banner = """ InkWell(
|
||||||
|
onTap: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => const BusinessProfileFormSheet(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2)),
|
||||||
|
),"""
|
||||||
|
|
||||||
|
content = re.sub(old_banner, new_banner, content)
|
||||||
|
content = content.replace(" const SizedBox(height: 24),\n GridView.count(", " ),\n ),\n const SizedBox(height: 24),\n GridView.count(")
|
||||||
|
|
||||||
|
if "BusinessProfileFormSheet" not in content:
|
||||||
|
content = content.replace("import '../../vendor/presentation/vendors_list_screen.dart';", "import '../../vendor/presentation/vendors_list_screen.dart';\nimport '../../business/presentation/widgets/business_profile_form_sheet.dart';")
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/project_hub_screen.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
10
scratch/patch_import.py
Normal file
10
scratch/patch_import.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
if "add_customer_sheet.dart" not in content:
|
||||||
|
content = content.replace("import 'package:lucide_icons/lucide_icons.dart';", "import 'package:lucide_icons/lucide_icons.dart';\nimport '../../../sales/presentation/add_customer_sheet.dart';")
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
14
scratch/patch_imports.py
Normal file
14
scratch/patch_imports.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
imports_to_add = """
|
||||||
|
import '../../projects/presentation/projects_screen.dart';
|
||||||
|
import '../../projects/providers/project_mode_provider.dart';
|
||||||
|
"""
|
||||||
|
|
||||||
|
if "import '../../projects/presentation/projects_screen.dart';" not in content:
|
||||||
|
content = content.replace("import 'package:flutter/material.dart';", "import 'package:flutter/material.dart';" + imports_to_add)
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
print("Dashboard imports patched")
|
||||||
33
scratch/patch_invoice_builder.py
Normal file
33
scratch/patch_invoice_builder.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Read projectModeProvider to check if in Project Mode
|
||||||
|
if "project_mode_provider.dart" not in content:
|
||||||
|
content = content.replace("import '../../inventory/providers/products_provider.dart';", "import '../../inventory/providers/products_provider.dart';\nimport '../../projects/providers/project_mode_provider.dart';")
|
||||||
|
|
||||||
|
# In _buildAddLineItemDialog (inside showDialog builder)
|
||||||
|
# Oh wait, we need to read it inside the dialog, which has `Consumer(builder: (context, dialogRef, _))`
|
||||||
|
old_consumer = r""" Consumer\(
|
||||||
|
builder: \(context, dialogRef, _\) \{
|
||||||
|
final productsState = dialogRef\.watch\(productsProvider\);
|
||||||
|
return productsState\.when\("""
|
||||||
|
new_consumer = """ Consumer(
|
||||||
|
builder: (context, dialogRef, _) {
|
||||||
|
final isProjectMode = dialogRef.watch(projectModeProvider);
|
||||||
|
final productsState = dialogRef.watch(productsProvider);
|
||||||
|
|
||||||
|
// In project mode, skip product selector
|
||||||
|
if (isProjectMode) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return productsState.when("""
|
||||||
|
|
||||||
|
content = re.sub(old_consumer, new_consumer, content)
|
||||||
|
|
||||||
|
# Remove Making Chg and Other Chg fields if in project mode
|
||||||
|
# They are hardcoded as text fields. I can just wrap them in `if (!isProjectMode)` ... but `isProjectMode` isn't accessible directly unless we refactor the whole dialog.
|
||||||
|
# Since it's inside Consumer, the whole column is NOT inside the Consumer. The text fields are outside.
|
||||||
|
|
||||||
21
scratch/patch_invoice_builder2.py
Normal file
21
scratch/patch_invoice_builder2.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Replace the wrong definition back to what it might have been... oh wait, my first script just modified `void _buildAddLineItemDialog` which didn't exist so it failed!
|
||||||
|
# Which means `content = content.replace(old_def, new_def)` did nothing, but `isProjectMode` was inserted manually? Wait! No, my script inserted `isProjectMode` but not `ref.read`!
|
||||||
|
# Ah, I replaced `Consumer(builder: (context, dialogRef, _))` with `if (!isProjectMode) Consumer(...)`. That's why `isProjectMode` is undefined!
|
||||||
|
|
||||||
|
def fix_it():
|
||||||
|
global content
|
||||||
|
|
||||||
|
# Let's just find `void _showAddItemDialog() {`
|
||||||
|
old_def = " void _showAddItemDialog() {"
|
||||||
|
new_def = " void _showAddItemDialog() {\n final isProjectMode = ref.read(projectModeProvider);"
|
||||||
|
content = content.replace(old_def, new_def)
|
||||||
|
|
||||||
|
fix_it()
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
11
scratch/patch_premium_text_field.py
Normal file
11
scratch/patch_premium_text_field.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/core/widgets/premium_text_field.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
content = content.replace(" final TextEditingController? controller;", " final TextEditingController? controller;\n final FocusNode? focusNode;")
|
||||||
|
content = content.replace(" this.controller,", " this.controller,\n this.focusNode,")
|
||||||
|
content = content.replace(" controller: controller,", " controller: controller,\n focusNode: focusNode,")
|
||||||
|
|
||||||
|
with open('kifi-app/lib/core/widgets/premium_text_field.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
47
scratch/patch_project_controller.py
Normal file
47
scratch/patch_project_controller.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-api/src/main/java/com/kifi/api/controller/ProjectController.java', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Add import
|
||||||
|
content = content.replace(
|
||||||
|
'import com.kifi.api.entity.project.ProjectTask;',
|
||||||
|
'import com.kifi.api.entity.project.ProjectTask;\nimport com.kifi.api.entity.project.ProjectTaskComment;'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add endpoints
|
||||||
|
endpoints = """
|
||||||
|
@GetMapping("/tasks/{taskId}/comments")
|
||||||
|
public Flux<ProjectTaskComment> getComments(@PathVariable Long taskId) {
|
||||||
|
return projectService.getCommentsForTask(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/tasks/{taskId}/comments")
|
||||||
|
public Mono<ProjectTaskComment> addComment(
|
||||||
|
@PathVariable Long taskId,
|
||||||
|
@RequestBody CreateCommentRequest request,
|
||||||
|
Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return projectService.addComment(taskId, userId, request.getContent(), request.getImagesJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
static class CreateProjectRequest {
|
||||||
|
"""
|
||||||
|
|
||||||
|
content = content.replace(' @Data\n static class CreateProjectRequest {', endpoints)
|
||||||
|
|
||||||
|
# Add request class
|
||||||
|
request_class = """
|
||||||
|
@Data
|
||||||
|
static class CreateCommentRequest {
|
||||||
|
private String content;
|
||||||
|
private String imagesJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
content = re.sub(r'}\s*$', request_class.strip() + '\n', content)
|
||||||
|
|
||||||
|
with open('kifi-api/src/main/java/com/kifi/api/controller/ProjectController.java', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
42
scratch/patch_project_init.py
Normal file
42
scratch/patch_project_init.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
old_init = """class _AddProjectSheetState extends ConsumerState<AddProjectSheet> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
String _name = '';
|
||||||
|
String _description = '';
|
||||||
|
double _budget = 0;
|
||||||
|
int? _selectedCustomerId;
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {"""
|
||||||
|
|
||||||
|
new_init = """class _AddProjectSheetState extends ConsumerState<AddProjectSheet> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
String _name = '';
|
||||||
|
String _description = '';
|
||||||
|
double _budget = 0;
|
||||||
|
int? _selectedCustomerId;
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.project != null) {
|
||||||
|
_name = widget.project!.name;
|
||||||
|
_description = widget.project!.description ?? '';
|
||||||
|
_budget = widget.project!.budget ?? 0;
|
||||||
|
_selectedCustomerId = widget.project!.customerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {"""
|
||||||
|
|
||||||
|
content = content.replace(old_init, new_init)
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
43
scratch/patch_project_service.py
Normal file
43
scratch/patch_project_service.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-api/src/main/java/com/kifi/api/service/ProjectService.java', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Add import
|
||||||
|
content = content.replace(
|
||||||
|
'import com.kifi.api.entity.project.ProjectTask;',
|
||||||
|
'import com.kifi.api.entity.project.ProjectTask;\nimport com.kifi.api.entity.project.ProjectTaskComment;\nimport com.kifi.api.repository.ProjectTaskCommentRepository;\nimport io.r2dbc.postgresql.codec.Json;'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add repository field
|
||||||
|
content = content.replace(
|
||||||
|
'private final ProjectTaskRepository projectTaskRepository;',
|
||||||
|
'private final ProjectTaskRepository projectTaskRepository;\n private final ProjectTaskCommentRepository projectTaskCommentRepository;'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add comment methods
|
||||||
|
methods = """
|
||||||
|
public Flux<ProjectTaskComment> getCommentsForTask(Long taskId) {
|
||||||
|
return projectTaskCommentRepository.findByTaskIdOrderByCreatedAtAsc(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<ProjectTaskComment> addComment(Long taskId, Long userId, String content, String imagesJson) {
|
||||||
|
ProjectTaskComment comment = new ProjectTaskComment();
|
||||||
|
comment.setTaskId(taskId);
|
||||||
|
comment.setUserId(userId);
|
||||||
|
comment.setContent(content);
|
||||||
|
if (imagesJson != null && !imagesJson.isEmpty()) {
|
||||||
|
comment.setImages(Json.of(imagesJson));
|
||||||
|
}
|
||||||
|
comment.setCreatedAt(LocalDateTime.now());
|
||||||
|
return projectTaskCommentRepository.save(comment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
content = content.replace('}\n', methods, 1) # Note: this will replace the first `}\n` which might not be EOF if not careful, let's use regex for end of file
|
||||||
|
|
||||||
|
content = re.sub(r'}\s*$', methods.strip() + '\n}', content)
|
||||||
|
|
||||||
|
with open('kifi-api/src/main/java/com/kifi/api/service/ProjectService.java', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
33
scratch/patch_project_sheet.py
Normal file
33
scratch/patch_project_sheet.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Replace SmartSearchDropdown with DropdownButtonFormField
|
||||||
|
dropdown = """ DropdownButtonFormField<int>(
|
||||||
|
value: _selectedCustomerId,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Client (Optional)',
|
||||||
|
prefixIcon: const Icon(LucideIcons.users),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<int>(value: null, child: Text('None')),
|
||||||
|
...customersState.value!.map((c) => DropdownMenuItem<int>(
|
||||||
|
value: c.id,
|
||||||
|
child: Text(c.name),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCustomerId = val;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),"""
|
||||||
|
|
||||||
|
content = re.sub(r' SmartSearchDropdown<Customer>\([\s\S]*?\),', dropdown, content)
|
||||||
|
content = content.replace("import '../../../core/widgets/smart_search_dropdown.dart';", "")
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
10
scratch/patch_project_sheet_tint.py
Normal file
10
scratch/patch_project_sheet_tint.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Add filled: true, fillColor to DropdownButtonFormField
|
||||||
|
content = content.replace("border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),\n contentPadding:", "border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),\n filled: true,\n fillColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.05),\n contentPadding:")
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
11
scratch/patch_projects_screen.py
Normal file
11
scratch/patch_projects_screen.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/projects_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Remove the AppBar section
|
||||||
|
app_bar_regex = r" appBar: AppBar\(\n title: const Text\('Projects \(Agency OS\)'\),\n backgroundColor: Colors\.white,\n foregroundColor: Colors\.black,\n elevation: 0,\n \),\n"
|
||||||
|
content = re.sub(app_bar_regex, "", content)
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/projects_screen.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
42
scratch/patch_projects_screen_tweak.py
Normal file
42
scratch/patch_projects_screen_tweak.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/projects_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Add AppBar
|
||||||
|
if "appBar: AppBar" not in content:
|
||||||
|
content = content.replace(" return Scaffold(\n body: projectsAsync.when(", """ return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Projects'),
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: projectsAsync.when(""")
|
||||||
|
|
||||||
|
# Add Edit button to ListTile
|
||||||
|
old_list_tile = r""" trailing: const Icon\(Icons\.chevron_right\),
|
||||||
|
onTap: \(\) \{
|
||||||
|
Navigator\.push\("""
|
||||||
|
new_list_tile = """ trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.edit),
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => AddProjectSheet(project: p),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Icon(Icons.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push("""
|
||||||
|
content = re.sub(old_list_tile, new_list_tile, content)
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/projects_screen.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
22
scratch/patch_repo.py
Normal file
22
scratch/patch_repo.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/data/project_repository.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
new_get = """ Future<List<Project>> getProjects() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/projects');
|
||||||
|
if (response.data == null) return [];
|
||||||
|
return (response.data as List).map((p) => Project.fromJson(p)).toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.response?.statusCode == 404) {
|
||||||
|
throw Exception("Projects API endpoint not found. Please ensure the backend server is updated to the latest Docker image.");
|
||||||
|
}
|
||||||
|
throw Exception(e.message ?? 'Unknown error fetching projects');
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
|
||||||
|
content = re.sub(r' Future<List<Project>> getProjects\(\) async \{[\s\S]*?\}', new_get, content)
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/data/project_repository.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
13
scratch/patch_service2.py
Normal file
13
scratch/patch_service2.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-api/src/main/java/com/kifi/api/service/ProjectService.java', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Remove Json import
|
||||||
|
content = content.replace('import io.r2dbc.postgresql.codec.Json;\n', '')
|
||||||
|
|
||||||
|
# Fix addComment logic
|
||||||
|
content = content.replace('comment.setImages(Json.of(imagesJson));', 'comment.setImages(imagesJson);')
|
||||||
|
|
||||||
|
with open('kifi-api/src/main/java/com/kifi/api/service/ProjectService.java', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
39
scratch/patch_task_card.py
Normal file
39
scratch/patch_task_card.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
nav_logic = """
|
||||||
|
onGenerateInvoice: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => InvoiceBuilderScreen(
|
||||||
|
initialCustomerId: widget.project.customerId,
|
||||||
|
initialItems: [
|
||||||
|
InvoiceItem(
|
||||||
|
id: 0,
|
||||||
|
invoiceId: 0,
|
||||||
|
productId: null,
|
||||||
|
productName: t.title,
|
||||||
|
quantity: t.hoursLogged,
|
||||||
|
price: t.hourlyRate,
|
||||||
|
taxPercentage: 0,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
"""
|
||||||
|
|
||||||
|
content = content.replace(' onGenerateInvoice: () {\n // TODO: Navigate to AddInvoiceScreen with task data\n },', nav_logic.strip())
|
||||||
|
|
||||||
|
# Add imports
|
||||||
|
content = content.replace(
|
||||||
|
'import \'widgets/task_card.dart\';',
|
||||||
|
'import \'widgets/task_card.dart\';\nimport \'../../sales/presentation/invoice_builder_screen.dart\';\nimport \'../../sales/domain/invoice.dart\';'
|
||||||
|
)
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
45
scratch/patch_task_sheet.py
Normal file
45
scratch/patch_task_sheet.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
content = content.replace("import '../../providers/project_provider.dart';", "import '../../providers/project_provider.dart';\nimport '../../providers/user_provider.dart';\nimport '../../../../core/domain/user.dart';")
|
||||||
|
|
||||||
|
old_field = r""" PremiumTextField\(
|
||||||
|
labelText: 'Assignee Name \(Optional\)',
|
||||||
|
prefixIcon: const Icon\(LucideIcons\.user\),
|
||||||
|
onSaved: \(val\) => _assigneeName = val \?\? '',
|
||||||
|
\),"""
|
||||||
|
|
||||||
|
new_field = """ Autocomplete<User>(
|
||||||
|
optionsBuilder: (TextEditingValue textEditingValue) async {
|
||||||
|
if (textEditingValue.text.isEmpty) {
|
||||||
|
return const Iterable<User>.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await ref.read(userSearchProvider(textEditingValue.text).future);
|
||||||
|
} catch (e) {
|
||||||
|
return const Iterable<User>.empty();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
displayStringForOption: (User option) => '${option.name} (${option.email})',
|
||||||
|
onSelected: (User selection) {
|
||||||
|
_assigneeName = selection.name;
|
||||||
|
},
|
||||||
|
fieldViewBuilder: (context, textEditingController, focusNode, onFieldSubmitted) {
|
||||||
|
return PremiumTextField(
|
||||||
|
controller: textEditingController,
|
||||||
|
focusNode: focusNode,
|
||||||
|
labelText: 'Assignee Name (Optional)',
|
||||||
|
prefixIcon: const Icon(LucideIcons.user),
|
||||||
|
onSaved: (val) {
|
||||||
|
if (_assigneeName.isEmpty) _assigneeName = val ?? '';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),"""
|
||||||
|
|
||||||
|
content = re.sub(old_field, new_field, content)
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
39
scratch/patch_title.py
Normal file
39
scratch/patch_title.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
replacement = """
|
||||||
|
String title = '';
|
||||||
|
int logicalIndex = _currentIndex;
|
||||||
|
if (_currentIndex == 0) {
|
||||||
|
title = 'Dashboard';
|
||||||
|
} else {
|
||||||
|
if (isProjectMode) {
|
||||||
|
if (logicalIndex == 1) title = 'Projects';
|
||||||
|
logicalIndex--;
|
||||||
|
}
|
||||||
|
if (title.isEmpty) {
|
||||||
|
if (isBusinessMode) {
|
||||||
|
if (logicalIndex == 1) title = 'Business Hub';
|
||||||
|
else if (logicalIndex == 2) title = 'Statistics';
|
||||||
|
else if (logicalIndex == 3) title = 'My Accounts';
|
||||||
|
else title = 'Budgets';
|
||||||
|
} else {
|
||||||
|
if (logicalIndex == 1) title = 'Statistics';
|
||||||
|
else if (logicalIndex == 2) title = 'My Accounts';
|
||||||
|
else title = 'Budgets';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'String title;\n int logicalIndex = _currentIndex;.*?else title = \'Budgets\';\n }\n }\n }',
|
||||||
|
replacement.strip(),
|
||||||
|
content,
|
||||||
|
flags=re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
10
scratch/patch_user_provider.py
Normal file
10
scratch/patch_user_provider.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/providers/user_provider.dart', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
content = content.replace("import '../../core/network/dio_client.dart';", "import '../../../../core/network/dio_client.dart';")
|
||||||
|
content = content.replace("import '../../core/domain/user.dart';", "import '../../../../core/domain/user.dart';")
|
||||||
|
|
||||||
|
with open('kifi-app/lib/features/projects/providers/user_provider.dart', 'w') as f:
|
||||||
|
f.write(content)
|
||||||
Reference in New Issue
Block a user