From 2a106a8716b5de47b006a99c32e29494d1ac5ce5 Mon Sep 17 00:00:00 2001 From: Narayanan Madaswamy Date: Mon, 24 Aug 2026 22:57:06 +0530 Subject: [PATCH] Project management related changes --- .../api/controller/ProjectController.java | 138 +++++++ .../kifi/api/controller/UserController.java | 25 ++ .../com/kifi/api/entity/project/Project.java | 20 ++ .../kifi/api/entity/project/ProjectTask.java | 23 ++ .../entity/project/ProjectTaskComment.java | 18 + .../api/repository/ProjectRepository.java | 9 + .../ProjectTaskCommentRepository.java | 9 + .../api/repository/ProjectTaskRepository.java | 9 + .../kifi/api/repository/UserRepository.java | 1 + .../com/kifi/api/service/ProjectService.java | 117 ++++++ .../com/kifi/api/service/UserService.java | 25 ++ kifi-api/src/main/resources/application.yml | 3 + kifi-api/src/main/resources/schema.sql | 34 ++ kifi-app/lib/core/domain/user.dart | 15 + kifi-app/lib/core/network/dio_client.dart | 4 +- .../lib/core/widgets/premium_text_field.dart | 3 + .../auth/presentation/profile_screen.dart | 18 +- .../presentation/dashboard_screen.dart | 147 ++++---- .../projects/data/project_repository.dart | 131 +++++++ .../lib/features/projects/domain/project.dart | 47 +++ .../projects/domain/project_task.dart | 59 +++ .../projects/domain/project_task_comment.dart | 41 +++ .../presentation/project_board_screen.dart | 199 +++++++++++ .../presentation/project_hub_screen.dart | 127 +++++++ .../presentation/projects_screen.dart | 133 +++++++ .../widgets/add_project_sheet.dart | 194 ++++++++++ .../presentation/widgets/add_task_sheet.dart | 225 ++++++++++++ .../presentation/widgets/task_card.dart | 336 ++++++++++++++++++ .../widgets/task_details_sheet.dart | 248 +++++++++++++ .../providers/project_mode_provider.dart | 25 ++ .../projects/providers/project_provider.dart | 26 ++ .../projects/providers/user_provider.dart | 18 + .../presentation/invoice_builder_screen.dart | 51 ++- .../presentation/invoices_list_screen.dart | 2 +- .../presentation/vendors_list_screen.dart | 17 +- scratch/fix_imports.py | 21 ++ scratch/fix_task_sheet.py | 27 ++ scratch/patch_add_project.py | 39 ++ scratch/patch_autocomplete.py | 41 +++ scratch/patch_board.py | 38 ++ scratch/patch_board2.py | 44 +++ scratch/patch_board_screen.py | 40 +++ scratch/patch_builder.py | 46 +++ scratch/patch_dashboard.py | 103 ++++++ scratch/patch_dashboard2.py | 145 ++++++++ scratch/patch_dashboard_projects.py | 19 + scratch/patch_dashboard_tabs.py | 9 + scratch/patch_edit_project.py | 52 +++ scratch/patch_hub_inkwell.py | 39 ++ scratch/patch_import.py | 10 + scratch/patch_imports.py | 14 + scratch/patch_invoice_builder.py | 33 ++ scratch/patch_invoice_builder2.py | 21 ++ scratch/patch_premium_text_field.py | 11 + scratch/patch_project_controller.py | 47 +++ scratch/patch_project_init.py | 42 +++ scratch/patch_project_service.py | 43 +++ scratch/patch_project_sheet.py | 33 ++ scratch/patch_project_sheet_tint.py | 10 + scratch/patch_projects_screen.py | 11 + scratch/patch_projects_screen_tweak.py | 42 +++ scratch/patch_repo.py | 22 ++ scratch/patch_service2.py | 13 + scratch/patch_task_card.py | 39 ++ scratch/patch_task_sheet.py | 45 +++ scratch/patch_title.py | 39 ++ scratch/patch_user_provider.py | 10 + 67 files changed, 3565 insertions(+), 80 deletions(-) create mode 100644 kifi-api/src/main/java/com/kifi/api/controller/ProjectController.java create mode 100644 kifi-api/src/main/java/com/kifi/api/controller/UserController.java create mode 100644 kifi-api/src/main/java/com/kifi/api/entity/project/Project.java create mode 100644 kifi-api/src/main/java/com/kifi/api/entity/project/ProjectTask.java create mode 100644 kifi-api/src/main/java/com/kifi/api/entity/project/ProjectTaskComment.java create mode 100644 kifi-api/src/main/java/com/kifi/api/repository/ProjectRepository.java create mode 100644 kifi-api/src/main/java/com/kifi/api/repository/ProjectTaskCommentRepository.java create mode 100644 kifi-api/src/main/java/com/kifi/api/repository/ProjectTaskRepository.java create mode 100644 kifi-api/src/main/java/com/kifi/api/service/ProjectService.java create mode 100644 kifi-api/src/main/java/com/kifi/api/service/UserService.java create mode 100644 kifi-app/lib/core/domain/user.dart create mode 100644 kifi-app/lib/features/projects/data/project_repository.dart create mode 100644 kifi-app/lib/features/projects/domain/project.dart create mode 100644 kifi-app/lib/features/projects/domain/project_task.dart create mode 100644 kifi-app/lib/features/projects/domain/project_task_comment.dart create mode 100644 kifi-app/lib/features/projects/presentation/project_board_screen.dart create mode 100644 kifi-app/lib/features/projects/presentation/project_hub_screen.dart create mode 100644 kifi-app/lib/features/projects/presentation/projects_screen.dart create mode 100644 kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart create mode 100644 kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart create mode 100644 kifi-app/lib/features/projects/presentation/widgets/task_card.dart create mode 100644 kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart create mode 100644 kifi-app/lib/features/projects/providers/project_mode_provider.dart create mode 100644 kifi-app/lib/features/projects/providers/project_provider.dart create mode 100644 kifi-app/lib/features/projects/providers/user_provider.dart create mode 100644 scratch/fix_imports.py create mode 100644 scratch/fix_task_sheet.py create mode 100644 scratch/patch_add_project.py create mode 100644 scratch/patch_autocomplete.py create mode 100644 scratch/patch_board.py create mode 100644 scratch/patch_board2.py create mode 100644 scratch/patch_board_screen.py create mode 100644 scratch/patch_builder.py create mode 100644 scratch/patch_dashboard.py create mode 100644 scratch/patch_dashboard2.py create mode 100644 scratch/patch_dashboard_projects.py create mode 100644 scratch/patch_dashboard_tabs.py create mode 100644 scratch/patch_edit_project.py create mode 100644 scratch/patch_hub_inkwell.py create mode 100644 scratch/patch_import.py create mode 100644 scratch/patch_imports.py create mode 100644 scratch/patch_invoice_builder.py create mode 100644 scratch/patch_invoice_builder2.py create mode 100644 scratch/patch_premium_text_field.py create mode 100644 scratch/patch_project_controller.py create mode 100644 scratch/patch_project_init.py create mode 100644 scratch/patch_project_service.py create mode 100644 scratch/patch_project_sheet.py create mode 100644 scratch/patch_project_sheet_tint.py create mode 100644 scratch/patch_projects_screen.py create mode 100644 scratch/patch_projects_screen_tweak.py create mode 100644 scratch/patch_repo.py create mode 100644 scratch/patch_service2.py create mode 100644 scratch/patch_task_card.py create mode 100644 scratch/patch_task_sheet.py create mode 100644 scratch/patch_title.py create mode 100644 scratch/patch_user_provider.py diff --git a/kifi-api/src/main/java/com/kifi/api/controller/ProjectController.java b/kifi-api/src/main/java/com/kifi/api/controller/ProjectController.java new file mode 100644 index 0000000..a499269 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/ProjectController.java @@ -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 getProjects(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return projectService.getProjectsByUser(userId); + } + + @PostMapping + public Mono 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 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 deleteProject(@PathVariable Long projectId, Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return projectService.deleteProject(projectId, userId); + } + + @GetMapping("/{projectId}/tasks") + public Flux getTasks(@PathVariable Long projectId) { + return projectService.getTasksByProject(projectId); + } + + @PostMapping("/{projectId}/tasks") + public Mono 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 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 deleteTask(@PathVariable Long taskId) { + return projectService.deleteTask(taskId); + } + + + @GetMapping("/tasks/{taskId}/comments") + public Flux getComments(@PathVariable Long taskId) { + return projectService.getCommentsForTask(taskId); + } + + @PostMapping("/tasks/{taskId}/comments") + public Mono 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; + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/controller/UserController.java b/kifi-api/src/main/java/com/kifi/api/controller/UserController.java new file mode 100644 index 0000000..21ffa20 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/UserController.java @@ -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>> searchUsers(@RequestParam String q) { + return userService.searchUsers(q) + .collectList() + .map(ResponseEntity::ok); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/project/Project.java b/kifi-api/src/main/java/com/kifi/api/entity/project/Project.java new file mode 100644 index 0000000..35cabfe --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/project/Project.java @@ -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; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/project/ProjectTask.java b/kifi-api/src/main/java/com/kifi/api/entity/project/ProjectTask.java new file mode 100644 index 0000000..8d5d1ec --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/project/ProjectTask.java @@ -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; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/project/ProjectTaskComment.java b/kifi-api/src/main/java/com/kifi/api/entity/project/ProjectTaskComment.java new file mode 100644 index 0000000..6d732b8 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/project/ProjectTaskComment.java @@ -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; +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/ProjectRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/ProjectRepository.java new file mode 100644 index 0000000..90c0439 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/ProjectRepository.java @@ -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 { + Flux findByUserId(Long userId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/ProjectTaskCommentRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/ProjectTaskCommentRepository.java new file mode 100644 index 0000000..3adff98 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/ProjectTaskCommentRepository.java @@ -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 { + Flux findByTaskIdOrderByCreatedAtAsc(Long taskId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/ProjectTaskRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/ProjectTaskRepository.java new file mode 100644 index 0000000..0660a9e --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/ProjectTaskRepository.java @@ -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 { + Flux findByProjectId(Long projectId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/UserRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/UserRepository.java index bc8c6ea..21e9504 100644 --- a/kifi-api/src/main/java/com/kifi/api/repository/UserRepository.java +++ b/kifi-api/src/main/java/com/kifi/api/repository/UserRepository.java @@ -10,4 +10,5 @@ import reactor.core.publisher.Mono; @Repository public interface UserRepository extends R2dbcRepository { Mono findByEmail(String email); + Flux findByEmailContainingIgnoreCase(String email); } diff --git a/kifi-api/src/main/java/com/kifi/api/service/ProjectService.java b/kifi-api/src/main/java/com/kifi/api/service/ProjectService.java new file mode 100644 index 0000000..84ec746 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/ProjectService.java @@ -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 getProjectsByUser(Long userId) { + return projectRepository.findByUserId(userId); + } + + public Mono 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 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 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 getTasksByProject(Long projectId) { + return projectTaskRepository.findByProjectId(projectId); + } + + public Mono 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 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 deleteTask(Long taskId) { + return projectTaskRepository.deleteById(taskId); + } + + public Flux getCommentsForTask(Long taskId) { + return projectTaskCommentRepository.findByTaskIdOrderByCreatedAtAsc(taskId); + } + + public Mono 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); + } +} \ No newline at end of file diff --git a/kifi-api/src/main/java/com/kifi/api/service/UserService.java b/kifi-api/src/main/java/com/kifi/api/service/UserService.java new file mode 100644 index 0000000..7351661 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/UserService.java @@ -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 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; + }); + } +} diff --git a/kifi-api/src/main/resources/application.yml b/kifi-api/src/main/resources/application.yml index 793b107..8c3b0c7 100644 --- a/kifi-api/src/main/resources/application.yml +++ b/kifi-api/src/main/resources/application.yml @@ -1,6 +1,9 @@ spring: application: name: kifi-api + + codec: + max-in-memory-size: 10MB r2dbc: url: r2dbc:postgresql://103.125.129.116:5333/kifi-v2 diff --git a/kifi-api/src/main/resources/schema.sql b/kifi-api/src/main/resources/schema.sql index eab9264..302aa00 100644 --- a/kifi-api/src/main/resources/schema.sql +++ b/kifi-api/src/main/resources/schema.sql @@ -364,3 +364,37 @@ CREATE TABLE IF NOT EXISTS invoice_payments ( emi_installment_number INTEGER, 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 +); diff --git a/kifi-app/lib/core/domain/user.dart b/kifi-app/lib/core/domain/user.dart new file mode 100644 index 0000000..cf8a125 --- /dev/null +++ b/kifi-app/lib/core/domain/user.dart @@ -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 json) { + return User( + id: json['id'], + name: json['name'] ?? '', + email: json['email'] ?? '', + ); + } +} diff --git a/kifi-app/lib/core/network/dio_client.dart b/kifi-app/lib/core/network/dio_client.dart index 0733ab7..502fe26 100644 --- a/kifi-app/lib/core/network/dio_client.dart +++ b/kifi-app/lib/core/network/dio_client.dart @@ -15,8 +15,8 @@ class DioClient { DioClient._internal() : dio = Dio(BaseOptions( - baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2', - //baseUrl: 'http://192.168.0.104:8080/api/kifi-v2', // Local Mac IP for physical device testing + //baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2', + baseUrl: 'http://192.168.0.104:8080/api/kifi-v2', // Local Mac IP for physical device testing connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), )), diff --git a/kifi-app/lib/core/widgets/premium_text_field.dart b/kifi-app/lib/core/widgets/premium_text_field.dart index 92b26cf..b396fbc 100644 --- a/kifi-app/lib/core/widgets/premium_text_field.dart +++ b/kifi-app/lib/core/widgets/premium_text_field.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; class PremiumTextField extends StatelessWidget { final String labelText; final TextEditingController? controller; + final FocusNode? focusNode; final String? initialValue; final Widget? prefixIcon; final Widget? suffixIcon; @@ -20,6 +21,7 @@ class PremiumTextField extends StatelessWidget { super.key, required this.labelText, this.controller, + this.focusNode, this.initialValue, this.prefixIcon, this.suffixIcon, @@ -38,6 +40,7 @@ class PremiumTextField extends StatelessWidget { Widget build(BuildContext context) { return TextFormField( controller: controller, + focusNode: focusNode, initialValue: initialValue, decoration: InputDecoration( labelText: labelText, diff --git a/kifi-app/lib/features/auth/presentation/profile_screen.dart b/kifi-app/lib/features/auth/presentation/profile_screen.dart index 1e03b73..96329c0 100644 --- a/kifi-app/lib/features/auth/presentation/profile_screen.dart +++ b/kifi-app/lib/features/auth/presentation/profile_screen.dart @@ -13,6 +13,7 @@ import '../../transactions/providers/providers.dart'; import '../../../core/theme/theme_provider.dart'; import '../../business/providers/business_mode_provider.dart'; +import '../../projects/providers/project_mode_provider.dart'; import '../../business/presentation/settings/business_settings_screen.dart'; class ProfileScreen extends ConsumerStatefulWidget { @@ -141,7 +142,22 @@ class _ProfileScreenState extends ConsumerState { 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(); + }, + ); + }, + ) ], ); } diff --git a/kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart b/kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart index 5fea9ae..58bee5d 100644 --- a/kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart @@ -1,5 +1,9 @@ import 'dart:async'; 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:lucide_icons/lucide_icons.dart'; import 'package:intl/intl.dart'; @@ -150,6 +154,7 @@ class _DashboardScreenState extends ConsumerState { final walletsState = ref.watch(walletProvider); final invoicesState = ref.watch(invoicesProvider); final customersState = ref.watch(customersProvider); + final isProjectMode = ref.watch(projectModeProvider); final isBusinessMode = ref.watch(businessModeProvider); final allTransactions = transState.value ?? []; @@ -157,18 +162,21 @@ class _DashboardScreenState extends ConsumerState { final startDate = range.start; final endDate = range.end; - 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'; + 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'; } return Scaffold( @@ -400,11 +408,9 @@ class _DashboardScreenState extends ConsumerState { }); } - return IndexedStack( - index: _currentIndex, - children: [ - // ---------------- HOME TAB ---------------- - RefreshIndicator( + final List screens = []; + + screens.add(RefreshIndicator( onRefresh: _onRefresh, child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), @@ -624,13 +630,17 @@ class _DashboardScreenState extends ConsumerState { ], ), ), - ), + )); - // ---------------- BUSINESS TAB ---------------- - if (isBusinessMode) const BusinessHubScreen(), + if (isProjectMode) { + screens.add(const ProjectHubScreen()); + } - // ---------------- STATS TAB ---------------- - StatisticsTab( + if (isBusinessMode) { + screens.add(const BusinessHubScreen()); + } + + screens.add(StatisticsTab( transactions: transactions, wallets: safeWallets, categories: categoriesState.value ?? [], @@ -643,47 +653,64 @@ class _DashboardScreenState extends ConsumerState { }, onPickCustomDateRange: _pickCustomDateRange, onRefresh: _onRefresh, - ), - - // ---------------- WALLETS TAB ---------------- - const AccountsScreen(), - - // ---------------- BUDGETS TAB ---------------- - const BudgetScreen(), - ], + )); + 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: BottomNavigationBar( - currentIndex: isBusinessMode - ? (_currentIndex >= 3 ? _currentIndex + 1 : _currentIndex) - : (_currentIndex >= 2 ? _currentIndex + 1 : _currentIndex), - type: BottomNavigationBarType.fixed, - onTap: (index) { - final addIndex = isBusinessMode ? 3 : 2; - if (index == addIndex) { - Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen())); - } else { - setState(() { - _currentIndex = index > addIndex ? index - 1 : index; - }); - } - }, - selectedItemColor: Theme.of(context).colorScheme.primary, - unselectedItemColor: Colors.grey, - showSelectedLabels: true, - showUnselectedLabels: true, - items: [ - const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'), - if (isBusinessMode) - const BottomNavigationBarItem(icon: Icon(LucideIcons.briefcase), label: 'Business'), - const BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'), - const BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'), - const BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'), - const BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'), - ], - ), - ); + bottomNavigationBar: Builder( + builder: (context) { + final isProjectMode = ref.watch(projectModeProvider); + final isBusinessMode = ref.watch(businessModeProvider); + final List 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, + ); + } + ), ); } Widget _buildSummaryCard(BuildContext context, String title, double amount, Color color, IconData icon, {VoidCallback? onTap}) { diff --git a/kifi-app/lib/features/projects/data/project_repository.dart b/kifi-app/lib/features/projects/data/project_repository.dart new file mode 100644 index 0000000..ee83d42 --- /dev/null +++ b/kifi-app/lib/features/projects/data/project_repository.dart @@ -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> 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 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 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 deleteProject(int projectId) async { + await _dio.delete('/projects/$projectId'); + } + + Future> getTasks(int projectId) async { + final response = await _dio.get('/projects/$projectId/tasks'); + return (response.data as List).map((t) => ProjectTask.fromJson(t)).toList(); + } + + Future 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 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 deleteTask(int taskId) async { + await _dio.delete('/projects/tasks/$taskId'); + } +Future> 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 addComment({ + required int taskId, + required String content, + List? 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); + } +} diff --git a/kifi-app/lib/features/projects/domain/project.dart b/kifi-app/lib/features/projects/domain/project.dart new file mode 100644 index 0000000..ca8e553 --- /dev/null +++ b/kifi-app/lib/features/projects/domain/project.dart @@ -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 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 toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'customerId': customerId, + 'userId': userId, + 'budget': budget, + 'status': status, + 'createdAt': createdAt?.toIso8601String(), + }; + } +} diff --git a/kifi-app/lib/features/projects/domain/project_task.dart b/kifi-app/lib/features/projects/domain/project_task.dart new file mode 100644 index 0000000..70c3bd0 --- /dev/null +++ b/kifi-app/lib/features/projects/domain/project_task.dart @@ -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 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 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(), + }; + } +} diff --git a/kifi-app/lib/features/projects/domain/project_task_comment.dart b/kifi-app/lib/features/projects/domain/project_task_comment.dart new file mode 100644 index 0000000..f7e57eb --- /dev/null +++ b/kifi-app/lib/features/projects/domain/project_task_comment.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +class ProjectTaskComment { + final int id; + final int taskId; + final int userId; + final String content; + final List 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 json) { + List parsedImages = []; + if (json['images'] != null) { + if (json['images'] is String) { + try { + parsedImages = List.from(jsonDecode(json['images'])); + } catch (_) {} + } else if (json['images'] is List) { + parsedImages = List.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']), + ); + } +} diff --git a/kifi-app/lib/features/projects/presentation/project_board_screen.dart b/kifi-app/lib/features/projects/presentation/project_board_screen.dart new file mode 100644 index 0000000..8767c8f --- /dev/null +++ b/kifi-app/lib/features/projects/presentation/project_board_screen.dart @@ -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 createState() => _ProjectBoardScreenState(); +} + +class _ProjectBoardScreenState extends ConsumerState { + @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); +} diff --git a/kifi-app/lib/features/projects/presentation/project_hub_screen.dart b/kifi-app/lib/features/projects/presentation/project_hub_screen.dart new file mode 100644 index 0000000..b4d3f8e --- /dev/null +++ b/kifi-app/lib/features/projects/presentation/project_hub_screen.dart @@ -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)), + ], + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/projects/presentation/projects_screen.dart b/kifi-app/lib/features/projects/presentation/projects_screen.dart new file mode 100644 index 0000000..badd2b6 --- /dev/null +++ b/kifi-app/lib/features/projects/presentation/projects_screen.dart @@ -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 createState() => _ProjectsScreenState(); +} + +class _ProjectsScreenState extends ConsumerState { + 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'), + ), + ); + } +} diff --git a/kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart b/kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart new file mode 100644 index 0000000..fe922d9 --- /dev/null +++ b/kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart @@ -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 createState() => _AddProjectSheetState(); +} + +class _AddProjectSheetState extends ConsumerState { + final _formKey = GlobalKey(); + 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( + 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(value: null, child: Text('None')), + ...customersState.value!.map((c) => DropdownMenuItem( + 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 _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); + } + } +} diff --git a/kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart b/kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart new file mode 100644 index 0000000..2eff590 --- /dev/null +++ b/kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart @@ -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 createState() => _AddTaskSheetState(); +} + +class _AddTaskSheetState extends ConsumerState { + final _formKey = GlobalKey(); + 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( + initialValue: _isEditing && _assigneeName.isNotEmpty + ? TextEditingValue(text: _assigneeName) + : null, + optionsBuilder: (TextEditingValue textEditingValue) async { + if (textEditingValue.text.isEmpty) { + return const Iterable.empty(); + } + try { + return await ref.read(userSearchProvider(textEditingValue.text).future); + } catch (e) { + return const Iterable.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 _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); + } + } +} diff --git a/kifi-app/lib/features/projects/presentation/widgets/task_card.dart b/kifi-app/lib/features/projects/presentation/widgets/task_card.dart new file mode 100644 index 0000000..edca9a0 --- /dev/null +++ b/kifi-app/lib/features/projects/presentation/widgets/task_card.dart @@ -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( + 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( + 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 _buildStatusMenuItem(String value, String label, Color color) { + return PopupMenuItem( + 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), + ], + ], + ), + ); + } +} diff --git a/kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart b/kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart new file mode 100644 index 0000000..c69f3c6 --- /dev/null +++ b/kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart @@ -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 createState() => _TaskDetailsSheetState(); +} + +class _TaskDetailsSheetState extends ConsumerState { + final TextEditingController _commentController = TextEditingController(); + final List _imagesBase64 = []; + bool _isSubmitting = false; + + Future _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 _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, + ), + ], + ), + ], + ), + ); + } +} diff --git a/kifi-app/lib/features/projects/providers/project_mode_provider.dart b/kifi-app/lib/features/projects/providers/project_mode_provider.dart new file mode 100644 index 0000000..28638e4 --- /dev/null +++ b/kifi-app/lib/features/projects/providers/project_mode_provider.dart @@ -0,0 +1,25 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ProjectModeNotifier extends Notifier { + @override + bool build() { + _loadState(); + return false; // Default until loaded + } + + Future _loadState() async { + final prefs = await SharedPreferences.getInstance(); + state = prefs.getBool('is_project_mode') ?? false; + } + + Future toggleMode() async { + final prefs = await SharedPreferences.getInstance(); + state = !state; + await prefs.setBool('is_project_mode', state); + } +} + +final projectModeProvider = NotifierProvider(() { + return ProjectModeNotifier(); +}); diff --git a/kifi-app/lib/features/projects/providers/project_provider.dart b/kifi-app/lib/features/projects/providers/project_provider.dart new file mode 100644 index 0000000..4c3493a --- /dev/null +++ b/kifi-app/lib/features/projects/providers/project_provider.dart @@ -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((ref) { + return ProjectRepository(DioClient().dio); +}); + +final projectsProvider = FutureProvider>((ref) async { + final repo = ref.watch(projectRepositoryProvider); + return repo.getProjects(); +}); + +final projectTasksProvider = FutureProvider.family, int>((ref, projectId) async { + final repo = ref.watch(projectRepositoryProvider); + return repo.getTasks(projectId); +}); + +final projectTaskCommentsProvider = FutureProvider.family, int>((ref, taskId) async { + final repo = ref.watch(projectRepositoryProvider); + return repo.getComments(taskId); +}); diff --git a/kifi-app/lib/features/projects/providers/user_provider.dart b/kifi-app/lib/features/projects/providers/user_provider.dart new file mode 100644 index 0000000..1d60985 --- /dev/null +++ b/kifi-app/lib/features/projects/providers/user_provider.dart @@ -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, 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'); + } +}); diff --git a/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart b/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart index f1d7190..d9f92bc 100644 --- a/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart @@ -11,12 +11,16 @@ import '../domain/invoice.dart'; import '../providers/customers_provider.dart'; import '../domain/customer.dart'; import '../../inventory/providers/products_provider.dart'; +import '../../projects/providers/project_mode_provider.dart'; import '../../inventory/domain/product.dart'; import 'add_customer_sheet.dart'; import '../../transactions/providers/providers.dart'; class InvoiceBuilderScreen extends ConsumerStatefulWidget { - const InvoiceBuilderScreen({super.key}); + final List? initialItems; + final int? initialCustomerId; + + const InvoiceBuilderScreen({super.key, this.initialItems, this.initialCustomerId}); @override ConsumerState createState() => _InvoiceBuilderScreenState(); @@ -27,6 +31,29 @@ class _InvoiceBuilderScreenState extends ConsumerState { Customer? _selectedCustomer; final List _items = []; 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'; final TextEditingController _emiAmountCtrl = TextEditingController(); final TextEditingController _invoiceNumberCtrl = TextEditingController(text: 'INV-${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}'); @@ -104,6 +131,7 @@ class _InvoiceBuilderScreenState extends ConsumerState { } void _showAddItemDialog() { + final isProjectMode = ref.read(projectModeProvider); Product? selectedProduct; final TextEditingController descCtrl = TextEditingController(); final TextEditingController qtyCtrl = TextEditingController(text: '1'); @@ -126,7 +154,7 @@ class _InvoiceBuilderScreenState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Consumer( + if (!isProjectMode) Consumer( builder: (context, dialogRef, _) { final productsState = dialogRef.watch(productsProvider); return productsState.when( @@ -222,14 +250,16 @@ class _InvoiceBuilderScreenState extends ConsumerState { ), ], ), - const SizedBox(height: 12), - Row( - children: [ - Expanded(child: PremiumTextField(controller: makingCtrl, labelText: 'Making Chg', keyboardType: TextInputType.number)), - const SizedBox(width: 8), - Expanded(child: PremiumTextField(controller: otherCtrl, labelText: 'Other Chg', keyboardType: TextInputType.number)), - ], - ), + if (!isProjectMode) ...[ + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: PremiumTextField(controller: makingCtrl, labelText: 'Making 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 { @override Widget build(BuildContext context) { + _loadInitialCustomer(); final customersState = ref.watch(customersProvider); final formatCurrency = NumberFormat.currency(symbol: '₹'); diff --git a/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart b/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart index 3266bd1..89c48b6 100644 --- a/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart @@ -136,7 +136,7 @@ class _InvoicesListScreenState extends ConsumerState { final formatDate = DateFormat('MMM dd, yyyy'); return Scaffold( - backgroundColor: Colors.grey[50], + backgroundColor: Colors.grey[100], appBar: AppBar( title: const Text('Invoices'), elevation: 0, diff --git a/kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart b/kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart index eb20f4c..3a2238c 100644 --- a/kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart +++ b/kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart @@ -20,25 +20,30 @@ class _VendorsListScreenState extends ConsumerState { final darkTheme = Theme.of(context).brightness == Brightness.dark; return Scaffold( + backgroundColor: Colors.grey[100], appBar: AppBar( - title: const Text('Vendors'), + title: const Text('Vendors', style: TextStyle(fontWeight: FontWeight.bold)), elevation: 0, - backgroundColor: Colors.transparent, + backgroundColor: Colors.white, + foregroundColor: Colors.black, + centerTitle: true, ), body: Column( children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0), + Container( + color: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: TextField( decoration: InputDecoration( hintText: 'Search vendors...', - prefixIcon: const Icon(LucideIcons.search), + prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), filled: true, - fillColor: darkTheme ? Colors.grey[900] : Colors.grey[100], + fillColor: Colors.grey[100], border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none, ), + contentPadding: const EdgeInsets.symmetric(vertical: 14), ), onChanged: (val) => setState(() => _searchQuery = val), ), diff --git a/scratch/fix_imports.py b/scratch/fix_imports.py new file mode 100644 index 0000000..722bb33 --- /dev/null +++ b/scratch/fix_imports.py @@ -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) diff --git a/scratch/fix_task_sheet.py b/scratch/fix_task_sheet.py new file mode 100644 index 0000000..3a58c5f --- /dev/null +++ b/scratch/fix_task_sheet.py @@ -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) + diff --git a/scratch/patch_add_project.py b/scratch/patch_add_project.py new file mode 100644 index 0000000..185276c --- /dev/null +++ b/scratch/patch_add_project.py @@ -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( + 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) diff --git a/scratch/patch_autocomplete.py b/scratch/patch_autocomplete.py new file mode 100644 index 0000000..9a57064 --- /dev/null +++ b/scratch/patch_autocomplete.py @@ -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) diff --git a/scratch/patch_board.py b/scratch/patch_board.py new file mode 100644 index 0000000..61e7258 --- /dev/null +++ b/scratch/patch_board.py @@ -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) diff --git a/scratch/patch_board2.py b/scratch/patch_board2.py new file mode 100644 index 0000000..033054e --- /dev/null +++ b/scratch/patch_board2.py @@ -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) diff --git a/scratch/patch_board_screen.py b/scratch/patch_board_screen.py new file mode 100644 index 0000000..23b43ad --- /dev/null +++ b/scratch/patch_board_screen.py @@ -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) diff --git a/scratch/patch_builder.py b/scratch/patch_builder.py new file mode 100644 index 0000000..bafa15b --- /dev/null +++ b/scratch/patch_builder.py @@ -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? 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) diff --git a/scratch/patch_dashboard.py b/scratch/patch_dashboard.py new file mode 100644 index 0000000..985ac48 --- /dev/null +++ b/scratch/patch_dashboard.py @@ -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 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 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") diff --git a/scratch/patch_dashboard2.py b/scratch/patch_dashboard2.py new file mode 100644 index 0000000..c3c78b7 --- /dev/null +++ b/scratch/patch_dashboard2.py @@ -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 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 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") diff --git a/scratch/patch_dashboard_projects.py b/scratch/patch_dashboard_projects.py new file mode 100644 index 0000000..cf86aae --- /dev/null +++ b/scratch/patch_dashboard_projects.py @@ -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) diff --git a/scratch/patch_dashboard_tabs.py b/scratch/patch_dashboard_tabs.py new file mode 100644 index 0000000..fe80588 --- /dev/null +++ b/scratch/patch_dashboard_tabs.py @@ -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`. + diff --git a/scratch/patch_edit_project.py b/scratch/patch_edit_project.py new file mode 100644 index 0000000..8984eb0 --- /dev/null +++ b/scratch/patch_edit_project.py @@ -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) diff --git a/scratch/patch_hub_inkwell.py b/scratch/patch_hub_inkwell.py new file mode 100644 index 0000000..73114d1 --- /dev/null +++ b/scratch/patch_hub_inkwell.py @@ -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) diff --git a/scratch/patch_import.py b/scratch/patch_import.py new file mode 100644 index 0000000..f381e5b --- /dev/null +++ b/scratch/patch_import.py @@ -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) diff --git a/scratch/patch_imports.py b/scratch/patch_imports.py new file mode 100644 index 0000000..e498c76 --- /dev/null +++ b/scratch/patch_imports.py @@ -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") diff --git a/scratch/patch_invoice_builder.py b/scratch/patch_invoice_builder.py new file mode 100644 index 0000000..be2e8c2 --- /dev/null +++ b/scratch/patch_invoice_builder.py @@ -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. + diff --git a/scratch/patch_invoice_builder2.py b/scratch/patch_invoice_builder2.py new file mode 100644 index 0000000..8511d57 --- /dev/null +++ b/scratch/patch_invoice_builder2.py @@ -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) diff --git a/scratch/patch_premium_text_field.py b/scratch/patch_premium_text_field.py new file mode 100644 index 0000000..f6e4606 --- /dev/null +++ b/scratch/patch_premium_text_field.py @@ -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) diff --git a/scratch/patch_project_controller.py b/scratch/patch_project_controller.py new file mode 100644 index 0000000..0a49fc3 --- /dev/null +++ b/scratch/patch_project_controller.py @@ -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 getComments(@PathVariable Long taskId) { + return projectService.getCommentsForTask(taskId); + } + + @PostMapping("/tasks/{taskId}/comments") + public Mono 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) diff --git a/scratch/patch_project_init.py b/scratch/patch_project_init.py new file mode 100644 index 0000000..46cd4d2 --- /dev/null +++ b/scratch/patch_project_init.py @@ -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 { + final _formKey = GlobalKey(); + String _name = ''; + String _description = ''; + double _budget = 0; + int? _selectedCustomerId; + bool _isLoading = false; + + @override + Widget build(BuildContext context) {""" + +new_init = """class _AddProjectSheetState extends ConsumerState { + final _formKey = GlobalKey(); + 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) diff --git a/scratch/patch_project_service.py b/scratch/patch_project_service.py new file mode 100644 index 0000000..3c77734 --- /dev/null +++ b/scratch/patch_project_service.py @@ -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 getCommentsForTask(Long taskId) { + return projectTaskCommentRepository.findByTaskIdOrderByCreatedAtAsc(taskId); + } + + public Mono 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) diff --git a/scratch/patch_project_sheet.py b/scratch/patch_project_sheet.py new file mode 100644 index 0000000..0ea4da6 --- /dev/null +++ b/scratch/patch_project_sheet.py @@ -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( + 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(value: null, child: Text('None')), + ...customersState.value!.map((c) => DropdownMenuItem( + value: c.id, + child: Text(c.name), + )), + ], + onChanged: (val) { + setState(() { + _selectedCustomerId = val; + }); + }, + ),""" + +content = re.sub(r' SmartSearchDropdown\([\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) diff --git a/scratch/patch_project_sheet_tint.py b/scratch/patch_project_sheet_tint.py new file mode 100644 index 0000000..cdb3213 --- /dev/null +++ b/scratch/patch_project_sheet_tint.py @@ -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) diff --git a/scratch/patch_projects_screen.py b/scratch/patch_projects_screen.py new file mode 100644 index 0000000..37adad6 --- /dev/null +++ b/scratch/patch_projects_screen.py @@ -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) diff --git a/scratch/patch_projects_screen_tweak.py b/scratch/patch_projects_screen_tweak.py new file mode 100644 index 0000000..6d89aec --- /dev/null +++ b/scratch/patch_projects_screen_tweak.py @@ -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) diff --git a/scratch/patch_repo.py b/scratch/patch_repo.py new file mode 100644 index 0000000..f062ae4 --- /dev/null +++ b/scratch/patch_repo.py @@ -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> 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> getProjects\(\) async \{[\s\S]*?\}', new_get, content) + +with open('kifi-app/lib/features/projects/data/project_repository.dart', 'w') as f: + f.write(content) diff --git a/scratch/patch_service2.py b/scratch/patch_service2.py new file mode 100644 index 0000000..1f46108 --- /dev/null +++ b/scratch/patch_service2.py @@ -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) diff --git a/scratch/patch_task_card.py b/scratch/patch_task_card.py new file mode 100644 index 0000000..3c1c274 --- /dev/null +++ b/scratch/patch_task_card.py @@ -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) diff --git a/scratch/patch_task_sheet.py b/scratch/patch_task_sheet.py new file mode 100644 index 0000000..d83ce93 --- /dev/null +++ b/scratch/patch_task_sheet.py @@ -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( + optionsBuilder: (TextEditingValue textEditingValue) async { + if (textEditingValue.text.isEmpty) { + return const Iterable.empty(); + } + try { + return await ref.read(userSearchProvider(textEditingValue.text).future); + } catch (e) { + return const Iterable.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) diff --git a/scratch/patch_title.py b/scratch/patch_title.py new file mode 100644 index 0000000..2720ae4 --- /dev/null +++ b/scratch/patch_title.py @@ -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) diff --git a/scratch/patch_user_provider.py b/scratch/patch_user_provider.py new file mode 100644 index 0000000..814c924 --- /dev/null +++ b/scratch/patch_user_provider.py @@ -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)