Project management related changes

This commit is contained in:
2026-08-24 22:57:06 +05:30
parent 71b2389221
commit 2a106a8716
67 changed files with 3565 additions and 80 deletions

View File

@@ -0,0 +1,138 @@
package com.kifi.api.controller;
import com.kifi.api.entity.project.Project;
import com.kifi.api.entity.project.ProjectTask;
import com.kifi.api.entity.project.ProjectTaskComment;
import com.kifi.api.service.ProjectService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/kifi-v2/projects")
@RequiredArgsConstructor
public class ProjectController {
private final ProjectService projectService;
@GetMapping
public Flux<Project> getProjects(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return projectService.getProjectsByUser(userId);
}
@PostMapping
public Mono<Project> createProject(@RequestBody CreateProjectRequest request, Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return projectService.createProject(
userId,
request.getName(),
request.getDescription(),
request.getCustomerId(),
request.getBudget()
);
}
@PutMapping("/{projectId}")
public Mono<Project> updateProject(@PathVariable Long projectId, @RequestBody CreateProjectRequest request, Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return projectService.updateProject(
projectId,
userId,
request.getName(),
request.getDescription(),
request.getCustomerId(),
request.getBudget(),
request.getStatus()
);
}
@DeleteMapping("/{projectId}")
public Mono<Void> deleteProject(@PathVariable Long projectId, Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return projectService.deleteProject(projectId, userId);
}
@GetMapping("/{projectId}/tasks")
public Flux<ProjectTask> getTasks(@PathVariable Long projectId) {
return projectService.getTasksByProject(projectId);
}
@PostMapping("/{projectId}/tasks")
public Mono<ProjectTask> createTask(@PathVariable Long projectId, @RequestBody CreateTaskRequest request) {
return projectService.createTask(
projectId,
request.getTitle(),
request.getDescription(),
request.getAssigneeName(),
request.getAssigneeUserId(),
request.getIsBillable(),
request.getHourlyRate()
);
}
@PutMapping("/tasks/{taskId}")
public Mono<ProjectTask> updateTask(@PathVariable Long taskId, @RequestBody CreateTaskRequest request) {
return projectService.updateTask(
taskId,
request.getTitle(),
request.getDescription(),
request.getAssigneeName(),
request.getAssigneeUserId(),
request.getStatus(),
request.getIsBillable(),
request.getHourlyRate(),
request.getHoursLogged()
);
}
@DeleteMapping("/tasks/{taskId}")
public Mono<Void> deleteTask(@PathVariable Long taskId) {
return projectService.deleteTask(taskId);
}
@GetMapping("/tasks/{taskId}/comments")
public Flux<ProjectTaskComment> getComments(@PathVariable Long taskId) {
return projectService.getCommentsForTask(taskId);
}
@PostMapping("/tasks/{taskId}/comments")
public Mono<ProjectTaskComment> addComment(
@PathVariable Long taskId,
@RequestBody CreateCommentRequest request,
Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return projectService.addComment(taskId, userId, request.getContent(), request.getImagesJson());
}
@Data
static class CreateProjectRequest {
private String name;
private String description;
private Long customerId;
private java.math.BigDecimal budget;
private String status;
}
@Data
static class CreateTaskRequest {
private String title;
private String description;
private String assigneeName;
private Long assigneeUserId;
private String status;
private Boolean isBillable;
private java.math.BigDecimal hourlyRate;
private java.math.BigDecimal hoursLogged;
}
@Data
static class CreateCommentRequest {
private String content;
private String imagesJson;
}
}

View File

@@ -0,0 +1,25 @@
package com.kifi.api.controller;
import com.kifi.api.entity.User;
import com.kifi.api.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;
import java.util.List;
@RestController
@RequestMapping("/api/kifi-v2/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/search")
public Mono<ResponseEntity<List<User>>> searchUsers(@RequestParam String q) {
return userService.searchUsers(q)
.collectList()
.map(ResponseEntity::ok);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,9 @@
package com.kifi.api.repository;
import com.kifi.api.entity.project.Project;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
public interface ProjectRepository extends ReactiveCrudRepository<Project, Long> {
Flux<Project> findByUserId(Long userId);
}

View File

@@ -0,0 +1,9 @@
package com.kifi.api.repository;
import com.kifi.api.entity.project.ProjectTaskComment;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
public interface ProjectTaskCommentRepository extends ReactiveCrudRepository<ProjectTaskComment, Long> {
Flux<ProjectTaskComment> findByTaskIdOrderByCreatedAtAsc(Long taskId);
}

View File

@@ -0,0 +1,9 @@
package com.kifi.api.repository;
import com.kifi.api.entity.project.ProjectTask;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
public interface ProjectTaskRepository extends ReactiveCrudRepository<ProjectTask, Long> {
Flux<ProjectTask> findByProjectId(Long projectId);
}

View File

@@ -10,4 +10,5 @@ import reactor.core.publisher.Mono;
@Repository @Repository
public interface UserRepository extends R2dbcRepository<User, Long> { public interface UserRepository extends R2dbcRepository<User, Long> {
Mono<User> findByEmail(String email); Mono<User> findByEmail(String email);
Flux<User> findByEmailContainingIgnoreCase(String email);
} }

View File

@@ -0,0 +1,117 @@
package com.kifi.api.service;
import com.kifi.api.entity.project.Project;
import com.kifi.api.entity.project.ProjectTask;
import com.kifi.api.entity.project.ProjectTaskComment;
import com.kifi.api.repository.ProjectRepository;
import com.kifi.api.repository.ProjectTaskRepository;
import com.kifi.api.repository.ProjectTaskCommentRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
@Slf4j
public class ProjectService {
private final ProjectRepository projectRepository;
private final ProjectTaskRepository projectTaskRepository;
private final ProjectTaskCommentRepository projectTaskCommentRepository;
public Flux<Project> getProjectsByUser(Long userId) {
return projectRepository.findByUserId(userId);
}
public Mono<Project> createProject(Long userId, String name, String description, Long customerId, java.math.BigDecimal budget) {
Project p = new Project();
p.setUserId(userId);
p.setName(name);
p.setDescription(description);
p.setCustomerId(customerId);
p.setBudget(budget != null ? budget : java.math.BigDecimal.ZERO);
p.setStatus("ACTIVE");
p.setCreatedAt(LocalDateTime.now());
return projectRepository.save(p);
}
public Mono<Project> updateProject(Long projectId, Long userId, String name, String description, Long customerId, java.math.BigDecimal budget, String status) {
return projectRepository.findById(projectId)
.filter(p -> p.getUserId().equals(userId))
.flatMap(p -> {
if (name != null) p.setName(name);
if (description != null) p.setDescription(description);
if (customerId != null) p.setCustomerId(customerId);
if (budget != null) p.setBudget(budget);
if (status != null) p.setStatus(status);
return projectRepository.save(p);
});
}
public Mono<Void> deleteProject(Long projectId, Long userId) {
return projectRepository.findById(projectId)
.filter(p -> p.getUserId().equals(userId))
.flatMap(p -> projectTaskRepository.findByProjectId(projectId)
.flatMap(task -> projectTaskRepository.delete(task))
.then(projectRepository.delete(p))
);
}
public Flux<ProjectTask> getTasksByProject(Long projectId) {
return projectTaskRepository.findByProjectId(projectId);
}
public Mono<ProjectTask> createTask(Long projectId, String title, String description, String assigneeName, Long assigneeUserId, Boolean isBillable, java.math.BigDecimal hourlyRate) {
ProjectTask t = new ProjectTask();
t.setProjectId(projectId);
t.setTitle(title);
t.setDescription(description);
t.setAssigneeName(assigneeName);
t.setAssigneeUserId(assigneeUserId);
t.setStatus("TODO");
t.setIsBillable(isBillable != null ? isBillable : false);
t.setHourlyRate(hourlyRate != null ? hourlyRate : java.math.BigDecimal.ZERO);
t.setHoursLogged(java.math.BigDecimal.ZERO);
t.setCreatedAt(LocalDateTime.now());
return projectTaskRepository.save(t);
}
public Mono<ProjectTask> updateTask(Long taskId, String title, String description, String assigneeName, Long assigneeUserId, String status, Boolean isBillable, java.math.BigDecimal hourlyRate, java.math.BigDecimal hoursLogged) {
return projectTaskRepository.findById(taskId)
.flatMap(t -> {
if (title != null) t.setTitle(title);
if (description != null) t.setDescription(description);
if (assigneeName != null) t.setAssigneeName(assigneeName);
if (assigneeUserId != null) t.setAssigneeUserId(assigneeUserId);
if (status != null) t.setStatus(status);
if (isBillable != null) t.setIsBillable(isBillable);
if (hourlyRate != null) t.setHourlyRate(hourlyRate);
if (hoursLogged != null) t.setHoursLogged(hoursLogged);
return projectTaskRepository.save(t);
});
}
public Mono<Void> deleteTask(Long taskId) {
return projectTaskRepository.deleteById(taskId);
}
public Flux<ProjectTaskComment> getCommentsForTask(Long taskId) {
return projectTaskCommentRepository.findByTaskIdOrderByCreatedAtAsc(taskId);
}
public Mono<ProjectTaskComment> addComment(Long taskId, Long userId, String content, String imagesJson) {
ProjectTaskComment comment = new ProjectTaskComment();
comment.setTaskId(taskId);
comment.setUserId(userId);
comment.setContent(content);
if (imagesJson != null && !imagesJson.isEmpty()) {
comment.setImages(imagesJson);
}
comment.setCreatedAt(LocalDateTime.now());
return projectTaskCommentRepository.save(comment);
}
}

View File

@@ -0,0 +1,25 @@
package com.kifi.api.service;
import com.kifi.api.entity.User;
import com.kifi.api.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public Flux<User> searchUsers(String query) {
if (query == null || query.isBlank()) {
return Flux.empty();
}
return userRepository.findByEmailContainingIgnoreCase(query)
.map(user -> {
// Ensure password is not returned in search results
user.setPassword(null);
return user;
});
}
}

View File

@@ -1,6 +1,9 @@
spring: spring:
application: application:
name: kifi-api name: kifi-api
codec:
max-in-memory-size: 10MB
r2dbc: r2dbc:
url: r2dbc:postgresql://103.125.129.116:5333/kifi-v2 url: r2dbc:postgresql://103.125.129.116:5333/kifi-v2

View File

@@ -364,3 +364,37 @@ CREATE TABLE IF NOT EXISTS invoice_payments (
emi_installment_number INTEGER, emi_installment_number INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
customer_id INTEGER REFERENCES customers(id),
user_id INTEGER REFERENCES users(id),
budget DECIMAL(15, 2) DEFAULT 0.00,
status VARCHAR(50) DEFAULT 'ACTIVE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS project_tasks (
id SERIAL PRIMARY KEY,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
assignee_name VARCHAR(255),
assignee_user_id INTEGER REFERENCES users(id),
status VARCHAR(50) DEFAULT 'TODO', -- TODO, IN_PROGRESS, REVIEW, DONE
is_billable BOOLEAN DEFAULT false,
hourly_rate DECIMAL(15, 2) DEFAULT 0.00,
hours_logged DECIMAL(10, 2) DEFAULT 0.00,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS project_task_comments (
id SERIAL PRIMARY KEY,
task_id INTEGER REFERENCES project_tasks(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id),
content TEXT NOT NULL,
images TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -0,0 +1,15 @@
class User {
final int id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
name: json['name'] ?? '',
email: json['email'] ?? '',
);
}
}

View File

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

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
class PremiumTextField extends StatelessWidget { class PremiumTextField extends StatelessWidget {
final String labelText; final String labelText;
final TextEditingController? controller; final TextEditingController? controller;
final FocusNode? focusNode;
final String? initialValue; final String? initialValue;
final Widget? prefixIcon; final Widget? prefixIcon;
final Widget? suffixIcon; final Widget? suffixIcon;
@@ -20,6 +21,7 @@ class PremiumTextField extends StatelessWidget {
super.key, super.key,
required this.labelText, required this.labelText,
this.controller, this.controller,
this.focusNode,
this.initialValue, this.initialValue,
this.prefixIcon, this.prefixIcon,
this.suffixIcon, this.suffixIcon,
@@ -38,6 +40,7 @@ class PremiumTextField extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextFormField( return TextFormField(
controller: controller, controller: controller,
focusNode: focusNode,
initialValue: initialValue, initialValue: initialValue,
decoration: InputDecoration( decoration: InputDecoration(
labelText: labelText, labelText: labelText,

View File

@@ -13,6 +13,7 @@ import '../../transactions/providers/providers.dart';
import '../../../core/theme/theme_provider.dart'; import '../../../core/theme/theme_provider.dart';
import '../../business/providers/business_mode_provider.dart'; import '../../business/providers/business_mode_provider.dart';
import '../../projects/providers/project_mode_provider.dart';
import '../../business/presentation/settings/business_settings_screen.dart'; import '../../business/presentation/settings/business_settings_screen.dart';
class ProfileScreen extends ConsumerStatefulWidget { class ProfileScreen extends ConsumerStatefulWidget {
@@ -141,7 +142,22 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
Navigator.push(context, MaterialPageRoute(builder: (_) => const BusinessSettingsScreen())); Navigator.push(context, MaterialPageRoute(builder: (_) => const BusinessSettingsScreen()));
}, },
), ),
] ],
const Divider(height: 1),
Consumer(
builder: (context, ref, child) {
final isProjectMode = ref.watch(projectModeProvider);
return SwitchListTile(
secondary: const Icon(LucideIcons.trello),
title: const Text('Project Management (Agency OS)'),
subtitle: const Text('Task boards and billable hours'),
value: isProjectMode,
onChanged: (val) {
ref.read(projectModeProvider.notifier).toggleMode();
},
);
},
)
], ],
); );
} }

View File

@@ -1,5 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../projects/presentation/project_hub_screen.dart';
import '../../projects/presentation/projects_screen.dart';
import '../../projects/providers/project_mode_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@@ -150,6 +154,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final walletsState = ref.watch(walletProvider); final walletsState = ref.watch(walletProvider);
final invoicesState = ref.watch(invoicesProvider); final invoicesState = ref.watch(invoicesProvider);
final customersState = ref.watch(customersProvider); final customersState = ref.watch(customersProvider);
final isProjectMode = ref.watch(projectModeProvider);
final isBusinessMode = ref.watch(businessModeProvider); final isBusinessMode = ref.watch(businessModeProvider);
final allTransactions = transState.value ?? []; final allTransactions = transState.value ?? [];
@@ -157,18 +162,21 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final startDate = range.start; final startDate = range.start;
final endDate = range.end; final endDate = range.end;
String title; String title = 'Dashboard';
if (_currentIndex == 0) { int logicalIndex = _currentIndex;
title = 'Dashboard'; if (logicalIndex == 0) title = 'Dashboard';
} else if (isBusinessMode) { else {
if (_currentIndex == 1) title = 'Business Hub'; if (isProjectMode) {
else if (_currentIndex == 2) title = 'Statistics'; if (logicalIndex == 1) title = 'Projects';
else if (_currentIndex == 3) title = 'My Accounts'; logicalIndex--;
else title = 'Budgets'; }
} else { if (isBusinessMode) {
if (_currentIndex == 1) title = 'Statistics'; if (logicalIndex == 1) title = 'Business Hub';
else if (_currentIndex == 2) title = 'My Accounts'; logicalIndex--;
else title = 'Budgets'; }
if (logicalIndex == 1) title = 'Statistics';
else if (logicalIndex == 2) title = 'My Accounts';
else if (logicalIndex == 3) title = 'Budgets';
} }
return Scaffold( return Scaffold(
@@ -400,11 +408,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}); });
} }
return IndexedStack( final List<Widget> screens = [];
index: _currentIndex,
children: [ screens.add(RefreshIndicator(
// ---------------- HOME TAB ----------------
RefreshIndicator(
onRefresh: _onRefresh, onRefresh: _onRefresh,
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
@@ -624,13 +630,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
], ],
), ),
), ),
), ));
// ---------------- BUSINESS TAB ---------------- if (isProjectMode) {
if (isBusinessMode) const BusinessHubScreen(), screens.add(const ProjectHubScreen());
}
// ---------------- STATS TAB ---------------- if (isBusinessMode) {
StatisticsTab( screens.add(const BusinessHubScreen());
}
screens.add(StatisticsTab(
transactions: transactions, transactions: transactions,
wallets: safeWallets, wallets: safeWallets,
categories: categoriesState.value ?? [], categories: categoriesState.value ?? [],
@@ -643,47 +653,64 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}, },
onPickCustomDateRange: _pickCustomDateRange, onPickCustomDateRange: _pickCustomDateRange,
onRefresh: _onRefresh, onRefresh: _onRefresh,
), ));
screens.add(const AccountsScreen());
// ---------------- WALLETS TAB ---------------- screens.add(const BudgetScreen());
const AccountsScreen(),
int safeIndex = _currentIndex;
// ---------------- BUDGETS TAB ---------------- if (safeIndex >= screens.length) safeIndex = screens.length - 1;
const BudgetScreen(),
], return IndexedStack(
index: safeIndex,
children: screens,
); );
}, },
), ),
bottomNavigationBar: BottomNavigationBar( bottomNavigationBar: Builder(
currentIndex: isBusinessMode builder: (context) {
? (_currentIndex >= 3 ? _currentIndex + 1 : _currentIndex) final isProjectMode = ref.watch(projectModeProvider);
: (_currentIndex >= 2 ? _currentIndex + 1 : _currentIndex), final isBusinessMode = ref.watch(businessModeProvider);
type: BottomNavigationBarType.fixed, final List<BottomNavigationBarItem> navItems = [];
onTap: (index) { navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'));
final addIndex = isBusinessMode ? 3 : 2; if (isProjectMode) navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.trello), label: 'Projects'));
if (index == addIndex) { if (isBusinessMode) navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.briefcase), label: 'Business'));
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen())); navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'));
} else { navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'));
setState(() { navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'));
_currentIndex = index > addIndex ? index - 1 : index; navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'));
});
} int safeIndex = _currentIndex;
}, // Adjust _currentIndex for the display of bottom nav bar because of 'Add' button
selectedItemColor: Theme.of(context).colorScheme.primary, int addIdx = 1;
unselectedItemColor: Colors.grey, if (isProjectMode) addIdx++;
showSelectedLabels: true, if (isBusinessMode) addIdx++;
showUnselectedLabels: true, addIdx++; // For Stats
items: [
const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'), int displayIndex = _currentIndex;
if (isBusinessMode) if (_currentIndex >= addIdx) displayIndex++;
const BottomNavigationBarItem(icon: Icon(LucideIcons.briefcase), label: 'Business'),
const BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'), if (displayIndex >= navItems.length) displayIndex = navItems.length - 1;
const BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'),
const BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'), return BottomNavigationBar(
const BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'), currentIndex: displayIndex,
], type: BottomNavigationBarType.fixed,
), onTap: (index) {
); if (index == addIdx) {
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen()));
} else {
setState(() {
_currentIndex = index > addIdx ? index - 1 : index;
});
}
},
selectedItemColor: Theme.of(context).colorScheme.primary,
unselectedItemColor: Colors.grey,
showSelectedLabels: true,
showUnselectedLabels: true,
items: navItems,
);
}
), );
} }
Widget _buildSummaryCard(BuildContext context, String title, double amount, Color color, IconData icon, {VoidCallback? onTap}) { Widget _buildSummaryCard(BuildContext context, String title, double amount, Color color, IconData icon, {VoidCallback? onTap}) {

View File

@@ -0,0 +1,131 @@
import 'package:dio/dio.dart';
import '../domain/project.dart';
import '../domain/project_task.dart';
import 'dart:convert';
import '../domain/project_task_comment.dart';
class ProjectRepository {
final Dio _dio;
ProjectRepository(this._dio);
Future<List<Project>> getProjects() async {
try {
final response = await _dio.get('/projects');
if (response.data == null) return [];
return (response.data as List).map((p) => Project.fromJson(p)).toList();
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw Exception("Projects API endpoint not found. Please ensure the backend server is updated to the latest Docker image.");
}
throw Exception(e.message ?? 'Unknown error fetching projects');
}
}
Future<Project> createProject({
required String name,
String? description,
int? customerId,
double? budget,
}) async {
final response = await _dio.post('/projects', data: {
'name': name,
if (description != null) 'description': description,
if (customerId != null) 'customerId': customerId,
if (budget != null) 'budget': budget,
});
return Project.fromJson(response.data);
}
Future<Project> updateProject({
required int projectId,
String? name,
String? description,
int? customerId,
double? budget,
String? status,
}) async {
final response = await _dio.put('/projects/$projectId', data: {
if (name != null) 'name': name,
if (description != null) 'description': description,
if (customerId != null) 'customerId': customerId,
if (budget != null) 'budget': budget,
if (status != null) 'status': status,
});
return Project.fromJson(response.data);
}
Future<void> deleteProject(int projectId) async {
await _dio.delete('/projects/$projectId');
}
Future<List<ProjectTask>> getTasks(int projectId) async {
final response = await _dio.get('/projects/$projectId/tasks');
return (response.data as List).map((t) => ProjectTask.fromJson(t)).toList();
}
Future<ProjectTask> createTask({
required int projectId,
required String title,
String? description,
String? assigneeName,
int? assigneeUserId,
bool? isBillable,
double? hourlyRate,
}) async {
final response = await _dio.post('/projects/$projectId/tasks', data: {
'title': title,
if (description != null) 'description': description,
if (assigneeName != null) 'assigneeName': assigneeName,
if (assigneeUserId != null) 'assigneeUserId': assigneeUserId,
if (isBillable != null) 'isBillable': isBillable,
if (hourlyRate != null) 'hourlyRate': hourlyRate,
});
return ProjectTask.fromJson(response.data);
}
Future<ProjectTask> updateTask({
required int taskId,
String? title,
String? description,
String? assigneeName,
int? assigneeUserId,
String? status,
bool? isBillable,
double? hourlyRate,
double? hoursLogged,
}) async {
final response = await _dio.put('/projects/tasks/$taskId', data: {
if (title != null) 'title': title,
if (description != null) 'description': description,
if (assigneeName != null) 'assigneeName': assigneeName,
if (assigneeUserId != null) 'assigneeUserId': assigneeUserId,
if (status != null) 'status': status,
if (isBillable != null) 'isBillable': isBillable,
if (hourlyRate != null) 'hourlyRate': hourlyRate,
if (hoursLogged != null) 'hoursLogged': hoursLogged,
});
return ProjectTask.fromJson(response.data);
}
Future<void> deleteTask(int taskId) async {
await _dio.delete('/projects/tasks/$taskId');
}
Future<List<ProjectTaskComment>> getComments(int taskId) async {
final response = await _dio.get('/projects/tasks/$taskId/comments');
return (response.data as List).map((c) => ProjectTaskComment.fromJson(c)).toList();
}
Future<ProjectTaskComment> addComment({
required int taskId,
required String content,
List<String>? imagesBase64,
}) async {
final response = await _dio.post('/projects/tasks/$taskId/comments', data: {
'content': content,
if (imagesBase64 != null && imagesBase64.isNotEmpty)
'imagesJson': jsonEncode(imagesBase64),
});
return ProjectTaskComment.fromJson(response.data);
}
}

View File

@@ -0,0 +1,47 @@
class Project {
final int id;
final String name;
final String? description;
final int? customerId;
final int? userId;
final double budget;
final String status;
final DateTime? createdAt;
Project({
required this.id,
required this.name,
this.description,
this.customerId,
this.userId,
this.budget = 0.0,
this.status = 'ACTIVE',
this.createdAt,
});
factory Project.fromJson(Map<String, dynamic> json) {
return Project(
id: json['id'] as int,
name: json['name'] as String,
description: json['description'] as String?,
customerId: json['customerId'] as int?,
userId: json['userId'] as int?,
budget: (json['budget'] as num?)?.toDouble() ?? 0.0,
status: json['status'] as String? ?? 'ACTIVE',
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'customerId': customerId,
'userId': userId,
'budget': budget,
'status': status,
'createdAt': createdAt?.toIso8601String(),
};
}
}

View File

@@ -0,0 +1,59 @@
class ProjectTask {
final int id;
final int projectId;
final String title;
final String? description;
final String? assigneeName;
final int? assigneeUserId;
final String status;
final bool isBillable;
final double hourlyRate;
final double hoursLogged;
final DateTime? createdAt;
ProjectTask({
required this.id,
required this.projectId,
required this.title,
this.description,
this.assigneeName,
this.assigneeUserId,
this.status = 'TODO',
this.isBillable = false,
this.hourlyRate = 0.0,
this.hoursLogged = 0.0,
this.createdAt,
});
factory ProjectTask.fromJson(Map<String, dynamic> json) {
return ProjectTask(
id: json['id'] as int,
projectId: json['projectId'] as int,
title: json['title'] as String,
description: json['description'] as String?,
assigneeName: json['assigneeName'] as String?,
assigneeUserId: json['assigneeUserId'] as int?,
status: json['status'] as String? ?? 'TODO',
isBillable: json['isBillable'] as bool? ?? false,
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
hoursLogged: (json['hoursLogged'] as num?)?.toDouble() ?? 0.0,
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'projectId': projectId,
'title': title,
'description': description,
'assigneeName': assigneeName,
'assigneeUserId': assigneeUserId,
'status': status,
'isBillable': isBillable,
'hourlyRate': hourlyRate,
'hoursLogged': hoursLogged,
'createdAt': createdAt?.toIso8601String(),
};
}
}

View File

@@ -0,0 +1,41 @@
import 'dart:convert';
class ProjectTaskComment {
final int id;
final int taskId;
final int userId;
final String content;
final List<String> images;
final DateTime createdAt;
ProjectTaskComment({
required this.id,
required this.taskId,
required this.userId,
required this.content,
required this.images,
required this.createdAt,
});
factory ProjectTaskComment.fromJson(Map<String, dynamic> json) {
List<String> parsedImages = [];
if (json['images'] != null) {
if (json['images'] is String) {
try {
parsedImages = List<String>.from(jsonDecode(json['images']));
} catch (_) {}
} else if (json['images'] is List) {
parsedImages = List<String>.from(json['images']);
}
}
return ProjectTaskComment(
id: json['id'] as int,
taskId: json['taskId'] as int,
userId: json['userId'] as int,
content: json['content'] as String,
images: parsedImages,
createdAt: DateTime.parse(json['createdAt']),
);
}
}

View File

@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../domain/project.dart';
import '../providers/project_provider.dart';
import 'widgets/add_task_sheet.dart';
import 'widgets/task_card.dart';
import 'widgets/add_project_sheet.dart';
import '../../sales/presentation/invoice_builder_screen.dart';
import '../../sales/domain/invoice.dart';
class ProjectBoardScreen extends ConsumerStatefulWidget {
final Project project;
const ProjectBoardScreen({super.key, required this.project});
@override
ConsumerState<ProjectBoardScreen> createState() => _ProjectBoardScreenState();
}
class _ProjectBoardScreenState extends ConsumerState<ProjectBoardScreen> {
@override
Widget build(BuildContext context) {
final tasksAsync = ref.watch(projectTasksProvider(widget.project.id));
return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar(
title: Text(widget.project.name, style: const TextStyle(fontWeight: FontWeight.bold)),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
actions: [
IconButton(
icon: const Icon(LucideIcons.info),
tooltip: 'Project Details',
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => AddProjectSheet(project: widget.project),
);
},
),
],
),
body: tasksAsync.when(
data: (tasks) {
final columns = [
_ColumnDef('To Do', 'TODO', Colors.blueGrey, LucideIcons.circle),
_ColumnDef('In Progress', 'IN_PROGRESS', Colors.blue, LucideIcons.loader),
_ColumnDef('Review', 'REVIEW', Colors.orange, LucideIcons.eye),
_ColumnDef('Done', 'DONE', Colors.green, LucideIcons.checkCircle),
];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: columns.map((col) {
final columnTasks = tasks.where((t) => t.status == col.status).toList();
return _buildColumn(col, columnTasks, showAdd: col.status == 'TODO');
}).toList(),
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
),
);
}
Widget _buildColumn(_ColumnDef col, List tasks, {bool showAdd = false}) {
return Container(
width: 290,
margin: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: col.color.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: col.color.withValues(alpha: 0.12)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Column header
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: col.color.withValues(alpha: 0.08),
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
children: [
Icon(col.icon, size: 16, color: col.color),
const SizedBox(width: 8),
Text(
col.label,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: col.color,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: col.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${tasks.length}',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: col.color),
),
),
],
),
),
// Task cards
...tasks.map((t) => TaskCard(
task: t,
onEdit: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => AddTaskSheet(
projectId: widget.project.id,
task: t,
),
);
},
onGenerateInvoice: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => InvoiceBuilderScreen(
initialCustomerId: widget.project.customerId,
initialItems: [
InvoiceItem(
id: 0,
invoiceId: 0,
productId: null,
description: t.title,
quantity: t.hoursLogged,
unitPrice: t.hourlyRate,
total: t.hoursLogged * t.hourlyRate,
)
],
),
),
);
},
)),
if (showAdd)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: col.color,
side: BorderSide(color: col.color.withValues(alpha: 0.3)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(vertical: 10),
),
icon: const Icon(LucideIcons.plus, size: 16),
label: const Text('Add Task', style: TextStyle(fontWeight: FontWeight.w600)),
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => AddTaskSheet(projectId: widget.project.id),
);
},
),
),
),
const SizedBox(height: 8),
],
),
);
}
}
class _ColumnDef {
final String label;
final String status;
final Color color;
final IconData icon;
const _ColumnDef(this.label, this.status, this.color, this.icon);
}

View File

@@ -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)),
],
),
),
);
}
}

View File

@@ -0,0 +1,133 @@
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/project_provider.dart';
import 'project_board_screen.dart';
import 'widgets/add_project_sheet.dart';
class ProjectsScreen extends ConsumerStatefulWidget {
const ProjectsScreen({super.key});
@override
ConsumerState<ProjectsScreen> createState() => _ProjectsScreenState();
}
class _ProjectsScreenState extends ConsumerState<ProjectsScreen> {
String _searchQuery = "";
@override
Widget build(BuildContext context) {
final projectsAsync = ref.watch(projectsProvider);
return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar(
title: const Text('Projects', style: TextStyle(fontWeight: FontWeight.bold)),
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
centerTitle: true,
),
body: Column(
children: [
Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField(
decoration: InputDecoration(
hintText: 'Search projects...',
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
filled: true,
fillColor: Colors.grey[100],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: 14),
),
onChanged: (val) {
setState(() => _searchQuery = val);
},
),
),
Expanded(
child: projectsAsync.when(
data: (projects) {
final filteredProjects = projects.where((p) => p.name.toLowerCase().contains(_searchQuery.toLowerCase())).toList();
if (filteredProjects.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.folderKanban, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text('No projects found.', style: TextStyle(color: Colors.grey.shade500)),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: filteredProjects.length,
itemBuilder: (context, index) {
final p = filteredProjects[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
color: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(p.description ?? 'No description', style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(LucideIcons.edit, size: 18, color: Colors.grey.shade600),
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => AddProjectSheet(project: p),
);
},
),
Icon(Icons.chevron_right, color: Colors.grey.shade400),
],
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProjectBoardScreen(project: p),
),
);
},
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => const AddProjectSheet(),
);
},
icon: const Icon(Icons.add),
label: const Text('Add Project'),
),
);
}
}

View File

@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import '../../domain/project.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../sales/presentation/add_customer_sheet.dart';
import '../../providers/project_provider.dart';
import '../../../sales/providers/customers_provider.dart';
import '../../../../core/widgets/premium_text_field.dart';
class AddProjectSheet extends ConsumerStatefulWidget {
final Project? project;
const AddProjectSheet({super.key, this.project});
@override
ConsumerState<AddProjectSheet> createState() => _AddProjectSheetState();
}
class _AddProjectSheetState extends ConsumerState<AddProjectSheet> {
final _formKey = GlobalKey<FormState>();
String _name = '';
String _description = '';
double _budget = 0;
int? _selectedCustomerId;
bool _isLoading = false;
@override
void initState() {
super.initState();
if (widget.project != null) {
_name = widget.project!.name;
_description = widget.project!.description ?? '';
_budget = widget.project!.budget ?? 0;
_selectedCustomerId = widget.project!.customerId;
}
}
@override
Widget build(BuildContext context) {
final customersState = ref.watch(customersProvider);
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 24,
right: 24,
top: 24,
),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(widget.project == null ? 'New Project' : 'Edit Project', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)),
],
),
const SizedBox(height: 16),
PremiumTextField(
labelText: 'Project Name',
initialValue: _name,
prefixIcon: const Icon(LucideIcons.folder),
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
onSaved: (val) => _name = val!,
),
const SizedBox(height: 16),
PremiumTextField(
maxLines: 3,
labelText: 'Description (Optional)',
initialValue: _description,
prefixIcon: const Icon(LucideIcons.alignLeft),
onSaved: (val) => _description = val ?? '',
),
const SizedBox(height: 16),
if (customersState.hasValue)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: DropdownButtonFormField<int>(
value: _selectedCustomerId,
decoration: InputDecoration(
labelText: 'Client (Optional)',
prefixIcon: const Icon(LucideIcons.users),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
filled: true,
fillColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.05),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
items: [
const DropdownMenuItem<int>(value: null, child: Text('None')),
...customersState.value!.map((c) => DropdownMenuItem<int>(
value: c.id,
child: Text(c.name),
)),
],
onChanged: (val) {
setState(() {
_selectedCustomerId = val;
});
},
),
),
const SizedBox(width: 8),
Container(
height: 52, // Match the height of the dropdown approximately
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: const Icon(LucideIcons.plus),
color: Theme.of(context).colorScheme.primary,
tooltip: 'Add New Client',
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => const AddCustomerSheet(),
);
},
),
),
],
),
const SizedBox(height: 16),
PremiumTextField(
keyboardType: const TextInputType.numberWithOptions(decimal: true),
labelText: 'Budget (Optional)',
initialValue: _budget > 0 ? _budget.toString() : '',
prefixIcon: const Icon(LucideIcons.indianRupee),
onSaved: (val) => _budget = double.tryParse(val ?? '0') ?? 0,
),
const SizedBox(height: 24),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: _isLoading ? null : _submit,
child: _isLoading ? const CircularProgressIndicator(color: Colors.white) : Text(widget.project == null ? 'Create Project' : 'Save Changes', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
const SizedBox(height: 24),
],
),
),
),
),
);
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
_formKey.currentState!.save();
setState(() => _isLoading = true);
try {
final repo = ref.read(projectRepositoryProvider);
if (widget.project != null) {
await repo.updateProject(
projectId: widget.project!.id,
name: _name,
description: _description.isNotEmpty ? _description : null,
customerId: _selectedCustomerId,
budget: _budget,
status: widget.project!.status,
);
} else {
await repo.createProject(
name: _name,
description: _description.isNotEmpty ? _description : null,
customerId: _selectedCustomerId,
budget: _budget,
);
}
ref.invalidate(projectsProvider);
if (mounted) Navigator.pop(context);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
}

View File

@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import '../../../../core/widgets/premium_text_field.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../domain/project_task.dart';
import '../../providers/project_provider.dart';
import '../../providers/user_provider.dart';
import '../../../../core/domain/user.dart';
class AddTaskSheet extends ConsumerStatefulWidget {
final int projectId;
final ProjectTask? task;
const AddTaskSheet({super.key, required this.projectId, this.task});
@override
ConsumerState<AddTaskSheet> createState() => _AddTaskSheetState();
}
class _AddTaskSheetState extends ConsumerState<AddTaskSheet> {
final _formKey = GlobalKey<FormState>();
String _title = '';
String _description = '';
String _assigneeName = '';
bool _isBillable = false;
double _hourlyRate = 0;
bool _isLoading = false;
bool get _isEditing => widget.task != null;
@override
void initState() {
super.initState();
if (widget.task != null) {
_title = widget.task!.title;
_description = widget.task!.description ?? '';
_assigneeName = widget.task!.assigneeName ?? '';
_isBillable = widget.task!.isBillable;
_hourlyRate = widget.task!.hourlyRate;
}
}
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 24,
right: 24,
top: 24,
),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_isEditing ? 'Edit Task' : 'New Task', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)),
],
),
const SizedBox(height: 16),
PremiumTextField(
labelText: 'Task Title',
initialValue: _isEditing ? _title : null,
prefixIcon: const Icon(LucideIcons.checkSquare),
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
onSaved: (val) => _title = val!,
),
const SizedBox(height: 16),
PremiumTextField(
maxLines: 3,
labelText: 'Description (Optional)',
initialValue: _isEditing ? _description : null,
prefixIcon: const Icon(LucideIcons.alignLeft),
onSaved: (val) => _description = val ?? '',
),
const SizedBox(height: 16),
Autocomplete<User>(
initialValue: _isEditing && _assigneeName.isNotEmpty
? TextEditingValue(text: _assigneeName)
: null,
optionsBuilder: (TextEditingValue textEditingValue) async {
if (textEditingValue.text.isEmpty) {
return const Iterable<User>.empty();
}
try {
return await ref.read(userSearchProvider(textEditingValue.text).future);
} catch (e) {
return const Iterable<User>.empty();
}
},
displayStringForOption: (User option) => '${option.name} (${option.email})',
onSelected: (User selection) {
_assigneeName = selection.name;
},
optionsViewBuilder: (context, onSelected, options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(12),
color: Colors.white,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final User option = options.elementAt(index);
return ListTile(
title: Text(option.name, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(option.email, style: const TextStyle(color: Colors.grey)),
onTap: () => onSelected(option),
);
},
),
),
),
);
},
fieldViewBuilder: (context, textEditingController, focusNode, onFieldSubmitted) {
return PremiumTextField(
controller: textEditingController,
focusNode: focusNode,
labelText: 'Assignee Name (Optional)',
prefixIcon: const Icon(LucideIcons.user),
onSaved: (val) {
if (_assigneeName.isEmpty) _assigneeName = val ?? '';
},
);
},
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Is Billable?'),
subtitle: const Text('Task time will be invoiced'),
value: _isBillable,
onChanged: (val) {
setState(() {
_isBillable = val;
});
},
),
if (_isBillable) ...[
const SizedBox(height: 16),
PremiumTextField(
keyboardType: const TextInputType.numberWithOptions(decimal: true),
labelText: 'Hourly Rate',
initialValue: _isEditing && _hourlyRate > 0 ? _hourlyRate.toString() : null,
prefixIcon: const Icon(LucideIcons.indianRupee),
validator: (val) => _isBillable && (val == null || val.isEmpty) ? 'Required for billable tasks' : null,
onSaved: (val) => _hourlyRate = double.tryParse(val ?? '0') ?? 0,
),
],
const SizedBox(height: 24),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: _isLoading ? null : _submit,
child: _isLoading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(_isEditing ? 'Save Changes' : 'Create Task', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
const SizedBox(height: 24),
],
),
),
),
),
);
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
_formKey.currentState!.save();
setState(() => _isLoading = true);
try {
final repo = ref.read(projectRepositoryProvider);
if (_isEditing) {
await repo.updateTask(
taskId: widget.task!.id,
title: _title,
description: _description.isNotEmpty ? _description : null,
assigneeName: _assigneeName.isNotEmpty ? _assigneeName : null,
isBillable: _isBillable,
hourlyRate: _isBillable ? _hourlyRate : null,
);
} else {
await repo.createTask(
projectId: widget.projectId,
title: _title,
description: _description.isNotEmpty ? _description : null,
assigneeName: _assigneeName.isNotEmpty ? _assigneeName : null,
isBillable: _isBillable,
hourlyRate: _isBillable ? _hourlyRate : null,
);
}
ref.invalidate(projectTasksProvider(widget.projectId));
if (mounted) Navigator.pop(context);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
}

View File

@@ -0,0 +1,336 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../domain/project_task.dart';
import '../../providers/project_provider.dart';
import 'task_details_sheet.dart';
class TaskCard extends ConsumerWidget {
final ProjectTask task;
final VoidCallback onGenerateInvoice;
final VoidCallback? onEdit;
const TaskCard({
super.key,
required this.task,
required this.onGenerateInvoice,
this.onEdit,
});
Color _statusColor(String status) {
switch (status) {
case 'TODO':
return Colors.blueGrey;
case 'IN_PROGRESS':
return Colors.blue;
case 'REVIEW':
return Colors.orange;
case 'DONE':
return Colors.green;
default:
return Colors.grey;
}
}
String _statusLabel(String status) {
switch (status) {
case 'TODO':
return 'To Do';
case 'IN_PROGRESS':
return 'In Progress';
case 'REVIEW':
return 'Review';
case 'DONE':
return 'Done';
default:
return status;
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final statusColor = _statusColor(task.status);
return GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => TaskDetailsSheet(task: task),
);
},
onLongPress: () => _showTaskOptions(context, ref),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Padding(
padding: const EdgeInsets.all(14.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Status chip + overflow menu
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
_statusLabel(task.status),
style: TextStyle(
color: statusColor,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
_buildStatusDropdown(context, ref),
],
),
const SizedBox(height: 10),
// Task title
Text(
task.title,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
height: 1.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
// Description preview
if (task.description != null && task.description!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
task.description!,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600, height: 1.4),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 12),
// Bottom row: assignee + billable badge + invoice button
Row(
children: [
if (task.assigneeName != null && task.assigneeName!.isNotEmpty) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.indigo.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.user, size: 12, color: Colors.indigo.shade400),
const SizedBox(width: 4),
Text(
task.assigneeName!,
style: TextStyle(fontSize: 11, color: Colors.indigo.shade600, fontWeight: FontWeight.w500),
),
],
),
),
const SizedBox(width: 6),
],
if (task.isBillable)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.indianRupee, size: 11, color: Colors.green.shade600),
const SizedBox(width: 2),
Text(
'Billable',
style: TextStyle(color: Colors.green.shade700, fontSize: 11, fontWeight: FontWeight.w500),
),
],
),
),
const Spacer(),
if (task.isBillable && task.status == 'DONE')
SizedBox(
height: 28,
child: TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
backgroundColor: Colors.green.withValues(alpha: 0.1),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
icon: Icon(LucideIcons.receipt, size: 13, color: Colors.green.shade700),
label: Text('Invoice', style: TextStyle(fontSize: 11, color: Colors.green.shade700, fontWeight: FontWeight.w600)),
onPressed: onGenerateInvoice,
),
),
],
),
],
),
),
),
);
}
void _showTaskOptions(BuildContext context, WidgetRef ref) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (ctx) => Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
Text(task.title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 16),
ListTile(
leading: const Icon(LucideIcons.eye),
title: const Text('View Details'),
onTap: () {
Navigator.pop(ctx);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => TaskDetailsSheet(task: task),
);
},
),
if (onEdit != null)
ListTile(
leading: const Icon(LucideIcons.edit),
title: const Text('Edit Task'),
onTap: () {
Navigator.pop(ctx);
onEdit!();
},
),
ListTile(
leading: const Icon(LucideIcons.trash2, color: Colors.red),
title: const Text('Delete Task', style: TextStyle(color: Colors.red)),
onTap: () async {
Navigator.pop(ctx);
final confirm = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: const Text('Delete Task'),
content: Text('Are you sure you want to delete "${task.title}"?'),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(c, true),
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirm == true) {
try {
await ref.read(projectRepositoryProvider).deleteTask(task.id);
ref.invalidate(projectTasksProvider(task.projectId));
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
}
},
),
const SizedBox(height: 16),
],
),
),
),
);
}
Widget _buildStatusDropdown(BuildContext context, WidgetRef ref) {
return PopupMenuButton<String>(
initialValue: task.status,
tooltip: 'Change Status',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
child: Icon(LucideIcons.moreVertical, size: 18, color: Colors.grey.shade500),
onSelected: (newStatus) async {
if (newStatus != task.status) {
try {
await ref.read(projectRepositoryProvider).updateTask(
taskId: task.id,
status: newStatus,
);
ref.invalidate(projectTasksProvider(task.projectId));
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to update status: $e')));
}
}
}
},
itemBuilder: (context) => [
_buildStatusMenuItem('TODO', 'To Do', Colors.blueGrey),
_buildStatusMenuItem('IN_PROGRESS', 'In Progress', Colors.blue),
_buildStatusMenuItem('REVIEW', 'Review', Colors.orange),
_buildStatusMenuItem('DONE', 'Done', Colors.green),
],
);
}
PopupMenuItem<String> _buildStatusMenuItem(String value, String label, Color color) {
return PopupMenuItem<String>(
value: value,
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Text(label),
if (task.status == value) ...[
const Spacer(),
const Icon(LucideIcons.check, size: 16, color: Colors.green),
],
],
),
);
}
}

View File

@@ -0,0 +1,248 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:image_picker/image_picker.dart';
import '../../domain/project_task.dart';
import '../../providers/project_provider.dart';
import '../../../transactions/presentation/widgets/attachment_gallery_screen.dart';
class TaskDetailsSheet extends ConsumerStatefulWidget {
final ProjectTask task;
const TaskDetailsSheet({super.key, required this.task});
@override
ConsumerState<TaskDetailsSheet> createState() => _TaskDetailsSheetState();
}
class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
final TextEditingController _commentController = TextEditingController();
final List<String> _imagesBase64 = [];
bool _isSubmitting = false;
Future<void> _pickImage() async {
if (_imagesBase64.length >= 3) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 3 images allowed')));
return;
}
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(source: ImageSource.gallery, imageQuality: 50);
if (image != null) {
final bytes = await image.readAsBytes();
setState(() {
_imagesBase64.add(base64Encode(bytes));
});
}
}
Future<void> _submitComment() async {
if (_commentController.text.trim().isEmpty && _imagesBase64.isEmpty) return;
setState(() => _isSubmitting = true);
try {
final repo = ref.read(projectRepositoryProvider);
await repo.addComment(
taskId: widget.task.id,
content: _commentController.text.trim(),
imagesBase64: _imagesBase64,
);
_commentController.clear();
setState(() {
_imagesBase64.clear();
});
ref.invalidate(projectTaskCommentsProvider(widget.task.id));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error adding comment: $e')));
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
final commentsAsync = ref.watch(projectTaskCommentsProvider(widget.task.id));
return Container(
height: MediaQuery.of(context).size.height * 0.9,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
children: [
_buildHeader(context),
Expanded(
child: commentsAsync.when(
data: (comments) {
if (comments.isEmpty) return const Center(child: Text('No comments yet.'));
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: comments.length,
itemBuilder: (context, index) {
final c = comments[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
color: Colors.grey[100],
elevation: 0,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(c.content, style: const TextStyle(fontSize: 14)),
if (c.images.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: c.images.asMap().entries.map((entry) {
final imgIndex = entry.key;
final img = entry.value;
return GestureDetector(
onTap: () {
final allImages = c.images.map((i) => MemoryImage(base64Decode(i)) as ImageProvider).toList();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AttachmentGalleryScreen(
images: allImages,
initialIndex: imgIndex,
),
),
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(
base64Decode(img),
width: 80,
height: 80,
fit: BoxFit.cover,
),
),
);
}).toList(),
)
]
],
),
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
),
),
_buildCommentInput(),
],
),
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: Colors.grey.shade300))),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.task.title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
if (widget.task.description != null && widget.task.description!.isNotEmpty)
Text(widget.task.description!, style: const TextStyle(color: Colors.grey)),
],
),
),
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)),
],
),
);
}
Widget _buildCommentInput() {
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
left: 16,
right: 16,
top: 16,
),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade300)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_imagesBase64.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Wrap(
spacing: 8,
children: _imagesBase64.asMap().entries.map((entry) {
return Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(
base64Decode(entry.value),
width: 60,
height: 60,
fit: BoxFit.cover,
),
),
Positioned(
right: 0,
top: 0,
child: GestureDetector(
onTap: () {
setState(() {
_imagesBase64.removeAt(entry.key);
});
},
child: Container(
color: Colors.black54,
child: const Icon(LucideIcons.x, size: 16, color: Colors.white),
),
),
),
],
);
}).toList(),
),
),
Row(
children: [
IconButton(
icon: const Icon(LucideIcons.image),
onPressed: _pickImage,
),
Expanded(
child: TextField(
controller: _commentController,
decoration: const InputDecoration(
hintText: 'Add a comment...',
border: InputBorder.none,
),
),
),
_isSubmitting
? const Padding(padding: EdgeInsets.all(12.0), child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))
: IconButton(
icon: const Icon(LucideIcons.send, color: Colors.blue),
onPressed: _submitComment,
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ProjectModeNotifier extends Notifier<bool> {
@override
bool build() {
_loadState();
return false; // Default until loaded
}
Future<void> _loadState() async {
final prefs = await SharedPreferences.getInstance();
state = prefs.getBool('is_project_mode') ?? false;
}
Future<void> toggleMode() async {
final prefs = await SharedPreferences.getInstance();
state = !state;
await prefs.setBool('is_project_mode', state);
}
}
final projectModeProvider = NotifierProvider<ProjectModeNotifier, bool>(() {
return ProjectModeNotifier();
});

View File

@@ -0,0 +1,26 @@
import '../../../core/network/dio_client.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../auth/providers/auth_provider.dart';
import '../data/project_repository.dart';
import '../domain/project.dart';
import '../domain/project_task.dart';
import '../domain/project_task_comment.dart';
final projectRepositoryProvider = Provider<ProjectRepository>((ref) {
return ProjectRepository(DioClient().dio);
});
final projectsProvider = FutureProvider<List<Project>>((ref) async {
final repo = ref.watch(projectRepositoryProvider);
return repo.getProjects();
});
final projectTasksProvider = FutureProvider.family<List<ProjectTask>, int>((ref, projectId) async {
final repo = ref.watch(projectRepositoryProvider);
return repo.getTasks(projectId);
});
final projectTaskCommentsProvider = FutureProvider.family<List<ProjectTaskComment>, int>((ref, taskId) async {
final repo = ref.watch(projectRepositoryProvider);
return repo.getComments(taskId);
});

View File

@@ -0,0 +1,18 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../core/domain/user.dart';
import 'dart:async';
final userSearchProvider = FutureProvider.family<List<User>, String>((ref, query) async {
if (query.isEmpty) return [];
final dio = DioClient().dio;
final response = await dio.get('/users/search', queryParameters: {'q': query});
if (response.statusCode == 200) {
final List data = response.data;
return data.map((json) => User.fromJson(json)).toList();
} else {
throw Exception('Failed to search users');
}
});

View File

@@ -11,12 +11,16 @@ import '../domain/invoice.dart';
import '../providers/customers_provider.dart'; import '../providers/customers_provider.dart';
import '../domain/customer.dart'; import '../domain/customer.dart';
import '../../inventory/providers/products_provider.dart'; import '../../inventory/providers/products_provider.dart';
import '../../projects/providers/project_mode_provider.dart';
import '../../inventory/domain/product.dart'; import '../../inventory/domain/product.dart';
import 'add_customer_sheet.dart'; import 'add_customer_sheet.dart';
import '../../transactions/providers/providers.dart'; import '../../transactions/providers/providers.dart';
class InvoiceBuilderScreen extends ConsumerStatefulWidget { class InvoiceBuilderScreen extends ConsumerStatefulWidget {
const InvoiceBuilderScreen({super.key}); final List<InvoiceItem>? initialItems;
final int? initialCustomerId;
const InvoiceBuilderScreen({super.key, this.initialItems, this.initialCustomerId});
@override @override
ConsumerState<InvoiceBuilderScreen> createState() => _InvoiceBuilderScreenState(); ConsumerState<InvoiceBuilderScreen> createState() => _InvoiceBuilderScreenState();
@@ -27,6 +31,29 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
Customer? _selectedCustomer; Customer? _selectedCustomer;
final List<InvoiceItem> _items = []; final List<InvoiceItem> _items = [];
bool _isEmi = false; bool _isEmi = false;
@override
void initState() {
super.initState();
if (widget.initialItems != null) {
_items.addAll(widget.initialItems!);
}
}
void _loadInitialCustomer() {
if (widget.initialCustomerId != null && _selectedCustomer == null) {
final customers = ref.read(customersProvider).value;
if (customers != null) {
final cust = customers.where((c) => c.id == widget.initialCustomerId).firstOrNull;
if (cust != null) {
setState(() {
_selectedCustomer = cust;
});
}
}
}
}
String _emiCycle = 'MONTHLY'; String _emiCycle = 'MONTHLY';
final TextEditingController _emiAmountCtrl = TextEditingController(); final TextEditingController _emiAmountCtrl = TextEditingController();
final TextEditingController _invoiceNumberCtrl = TextEditingController(text: 'INV-${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}'); final TextEditingController _invoiceNumberCtrl = TextEditingController(text: 'INV-${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}');
@@ -104,6 +131,7 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
} }
void _showAddItemDialog() { void _showAddItemDialog() {
final isProjectMode = ref.read(projectModeProvider);
Product? selectedProduct; Product? selectedProduct;
final TextEditingController descCtrl = TextEditingController(); final TextEditingController descCtrl = TextEditingController();
final TextEditingController qtyCtrl = TextEditingController(text: '1'); final TextEditingController qtyCtrl = TextEditingController(text: '1');
@@ -126,7 +154,7 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Consumer( if (!isProjectMode) Consumer(
builder: (context, dialogRef, _) { builder: (context, dialogRef, _) {
final productsState = dialogRef.watch(productsProvider); final productsState = dialogRef.watch(productsProvider);
return productsState.when( return productsState.when(
@@ -222,14 +250,16 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
), ),
], ],
), ),
const SizedBox(height: 12), if (!isProjectMode) ...[
Row( const SizedBox(height: 12),
children: [ Row(
Expanded(child: PremiumTextField(controller: makingCtrl, labelText: 'Making Chg', keyboardType: TextInputType.number)), children: [
const SizedBox(width: 8), Expanded(child: PremiumTextField(controller: makingCtrl, labelText: 'Making Chg', keyboardType: TextInputType.number)),
Expanded(child: PremiumTextField(controller: otherCtrl, labelText: 'Other Chg', keyboardType: TextInputType.number)), const SizedBox(width: 8),
], Expanded(child: PremiumTextField(controller: otherCtrl, labelText: 'Other Chg', keyboardType: TextInputType.number)),
), ],
),
],
], ],
), ),
), ),
@@ -332,6 +362,7 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_loadInitialCustomer();
final customersState = ref.watch(customersProvider); final customersState = ref.watch(customersProvider);
final formatCurrency = NumberFormat.currency(symbol: ''); final formatCurrency = NumberFormat.currency(symbol: '');

View File

@@ -136,7 +136,7 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
final formatDate = DateFormat('MMM dd, yyyy'); final formatDate = DateFormat('MMM dd, yyyy');
return Scaffold( return Scaffold(
backgroundColor: Colors.grey[50], backgroundColor: Colors.grey[100],
appBar: AppBar( appBar: AppBar(
title: const Text('Invoices'), title: const Text('Invoices'),
elevation: 0, elevation: 0,

View File

@@ -20,25 +20,30 @@ class _VendorsListScreenState extends ConsumerState<VendorsListScreen> {
final darkTheme = Theme.of(context).brightness == Brightness.dark; final darkTheme = Theme.of(context).brightness == Brightness.dark;
return Scaffold( return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar( appBar: AppBar(
title: const Text('Vendors'), title: const Text('Vendors', style: TextStyle(fontWeight: FontWeight.bold)),
elevation: 0, elevation: 0,
backgroundColor: Colors.transparent, backgroundColor: Colors.white,
foregroundColor: Colors.black,
centerTitle: true,
), ),
body: Column( body: Column(
children: [ children: [
Padding( Container(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0), color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField( child: TextField(
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search vendors...', hintText: 'Search vendors...',
prefixIcon: const Icon(LucideIcons.search), prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
filled: true, filled: true,
fillColor: darkTheme ? Colors.grey[900] : Colors.grey[100], fillColor: Colors.grey[100],
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
contentPadding: const EdgeInsets.symmetric(vertical: 14),
), ),
onChanged: (val) => setState(() => _searchQuery = val), onChanged: (val) => setState(() => _searchQuery = val),
), ),

21
scratch/fix_imports.py Normal file
View File

@@ -0,0 +1,21 @@
import re
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'r') as f:
content = f.read()
if "add_project_sheet.dart" not in content:
content = content.replace("import 'widgets/task_card.dart';", "import 'widgets/task_card.dart';\nimport 'widgets/add_project_sheet.dart';")
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'w') as f:
f.write(content)
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'r') as f:
content = f.read()
if "project.dart" not in content:
content = content.replace("import 'package:flutter/material.dart';", "import 'package:flutter/material.dart';\nimport '../../domain/project.dart';")
content = content.replace("await repo.updateProject(\n id: widget.project!.id,", "await repo.updateProject(\n projectId: widget.project!.id,")
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'w') as f:
f.write(content)

27
scratch/fix_task_sheet.py Normal file
View File

@@ -0,0 +1,27 @@
import re
with open('kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart', 'r') as f:
content = f.read()
content = content.replace("import '../../../../core/widgets/bottom_sheet_container.dart';", "")
content = content.replace(" return BottomSheetContainer(\n title: 'New Task',\n child: Form(",
" return Container(\n padding: EdgeInsets.only(\n bottom: MediaQuery.of(context).viewInsets.bottom,\n left: 24,\n right: 24,\n top: 24,\n ),\n decoration: const BoxDecoration(\n color: Colors.white,\n borderRadius: BorderRadius.vertical(top: Radius.circular(24)),\n ),\n child: SafeArea(\n child: Form(")
header = """ children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('New Task', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)),
],
),
const SizedBox(height: 16),
PremiumTextField("""
content = content.replace(" children: [\n PremiumTextField(", header)
content = content.replace(" );\n }\n", " ),\n );\n }\n") # Adding missing parenthesis for SafeArea and Container
with open('kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart', 'w') as f:
f.write(content)

View File

@@ -0,0 +1,39 @@
import re
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'r') as f:
content = f.read()
# Add import
content = content.replace(
'import \'../../../sales/providers/customers_provider.dart\';',
'import \'../../../sales/providers/customers_provider.dart\';\nimport \'../../../sales/domain/customer.dart\';'
)
# Fix SmartSearchDropdown
old_dropdown = """ SmartSearchDropdown(
label: 'Client (Optional)',
items: customersState.value!.map((c) => DropdownMenuItem(value: c.id.toString(), child: Text(c.name))).toList(),
value: _selectedCustomerId?.toString(),
onChanged: (val) {
setState(() {
_selectedCustomerId = val != null ? int.parse(val) : null;
});
},
),"""
new_dropdown = """ SmartSearchDropdown<Customer>(
labelText: 'Client (Optional)',
items: customersState.value!,
itemAsString: (c) => c.name,
value: customersState.value!.where((c) => c.id == _selectedCustomerId).firstOrNull,
onChanged: (val) {
setState(() {
_selectedCustomerId = val?.id;
});
},
),"""
content = content.replace(old_dropdown, new_dropdown)
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'w') as f:
f.write(content)

View File

@@ -0,0 +1,41 @@
import re
with open('kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart', 'r') as f:
content = f.read()
old_autocomplete = r""" fieldViewBuilder: \(context, textEditingController, focusNode, onFieldSubmitted\) \{
return PremiumTextField\("""
new_autocomplete = """ optionsViewBuilder: (context, onSelected, options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(12),
color: Colors.white,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final User option = options.elementAt(index);
return ListTile(
title: Text(option.name, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(option.email, style: const TextStyle(color: Colors.grey)),
onTap: () => onSelected(option),
);
},
),
),
),
);
},
fieldViewBuilder: (context, textEditingController, focusNode, onFieldSubmitted) {
return PremiumTextField("""
content = re.sub(old_autocomplete, new_autocomplete, content)
with open('kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart', 'w') as f:
f.write(content)

38
scratch/patch_board.py Normal file
View File

@@ -0,0 +1,38 @@
import re
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'r') as f:
content = f.read()
# Add button for "To Do" column
content = content.replace(
'_buildColumn(\'To Do\', todo),',
'_buildColumn(\'To Do\', todo, showAdd: true),'
)
content = content.replace(
'Widget _buildColumn(String title, List tasks) {',
'Widget _buildColumn(String title, List tasks, {bool showAdd = false}) {'
)
add_button_code = """
if (showAdd)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextButton.icon(
icon: const Icon(Icons.add),
label: const Text('Add Task'),
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => AddTaskSheet(projectId: widget.project.id),
);
},
),
),
const SizedBox(height: 16),
"""
content = content.replace(' const SizedBox(height: 16),\n ],\n ),\n );\n }', add_button_code + ' ],\n ),\n );\n }')
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'w') as f:
f.write(content)

44
scratch/patch_board2.py Normal file
View File

@@ -0,0 +1,44 @@
import re
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'r') as f:
content = f.read()
# Replace old Card with TaskCard
old_card = """ ...tasks.map((t) => Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t.title, style: const TextStyle(fontWeight: FontWeight.bold)),
if (t.isBillable) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: Colors.green[50], borderRadius: BorderRadius.circular(4)),
child: const Text('Billable', style: TextStyle(color: Colors.green, fontSize: 12)),
)
]
],
),
),
)),"""
new_card = """ ...tasks.map((t) => TaskCard(
task: t,
onGenerateInvoice: () {
// TODO: Navigate to AddInvoiceScreen with task data
},
)),"""
content = content.replace(old_card, new_card)
# Add import
content = content.replace(
'import \'widgets/add_task_sheet.dart\';',
'import \'widgets/add_task_sheet.dart\';\nimport \'widgets/task_card.dart\';'
)
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'w') as f:
f.write(content)

View File

@@ -0,0 +1,40 @@
import re
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'r') as f:
content = f.read()
# Add edit button to AppBar
old_app_bar = r""" appBar: AppBar\(
title: Text\(widget\.project\.name\),
backgroundColor: Colors\.white,
foregroundColor: Colors\.black,
elevation: 0,
\),"""
new_app_bar = """ appBar: AppBar(
title: Text(widget.project.name),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
tooltip: 'Project Details',
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => AddProjectSheet(project: widget.project),
);
},
),
],
),"""
content = re.sub(old_app_bar, new_app_bar, content)
if "AddProjectSheet" not in content:
content = content.replace("import 'widgets/add_task_sheet.dart';", "import 'widgets/add_task_sheet.dart';\nimport 'widgets/add_project_sheet.dart';")
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'w') as f:
f.write(content)

46
scratch/patch_builder.py Normal file
View File

@@ -0,0 +1,46 @@
import re
with open('kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart', 'r') as f:
content = f.read()
# Add initialItems to constructor
content = content.replace(
'const InvoiceBuilderScreen({super.key});',
'final List<InvoiceItem>? initialItems;\n final int? initialCustomerId;\n\n const InvoiceBuilderScreen({super.key, this.initialItems, this.initialCustomerId});'
)
# Populate initial items
init_state = """
@override
void initState() {
super.initState();
if (widget.initialItems != null) {
_items.addAll(widget.initialItems!);
}
}
void _loadInitialCustomer() {
if (widget.initialCustomerId != null && _selectedCustomer == null) {
final customers = ref.read(customersProvider).value;
if (customers != null) {
final cust = customers.where((c) => c.id == widget.initialCustomerId).firstOrNull;
if (cust != null) {
setState(() {
_selectedCustomer = cust;
});
}
}
}
}
"""
content = content.replace(' bool _isEmi = false;', ' bool _isEmi = false;\n' + init_state)
# Call _loadInitialCustomer in build
content = content.replace(
'Widget build(BuildContext context) {',
'Widget build(BuildContext context) {\n _loadInitialCustomer();'
)
with open('kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart', 'w') as f:
f.write(content)

103
scratch/patch_dashboard.py Normal file
View File

@@ -0,0 +1,103 @@
import re
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'r') as f:
content = f.read()
home_start_idx = content.find("RefreshIndicator(\n onRefresh: _onRefresh,")
home_end_idx = content.find("// ---------------- PROJECTS TAB ----------------")
home_widget = content[home_start_idx:home_end_idx].strip()
if home_widget.endswith(","):
home_widget = home_widget[:-1]
stats_start_idx = content.find("StatisticsTab(")
stats_end_idx = content.find("// ---------------- WALLETS TAB ----------------")
stats_widget = content[stats_start_idx:stats_end_idx].strip()
if stats_widget.endswith(","):
stats_widget = stats_widget[:-1]
# We need to replace everything from `return IndexedStack(` to the end of bottom navbar
old_start_idx = content.find("return IndexedStack(")
old_end_idx = content.find(" );\n }\n\n Widget _buildSummaryCard")
if old_start_idx == -1 or old_end_idx == -1:
print("Could not find bounds")
exit(1)
new_code = """final List<Widget> screens = [];
screens.add(""" + home_widget + """);
if (isProjectMode) {
screens.add(const ProjectsScreen());
}
if (isBusinessMode) {
screens.add(const BusinessHubScreen());
}
screens.add(""" + stats_widget + """);
final addIndex = screens.length;
screens.add(const SizedBox.shrink()); // Placeholder for Add
screens.add(const AccountsScreen());
screens.add(const BudgetScreen());
int safeIndex = _currentIndex;
if (safeIndex >= screens.length) safeIndex = screens.length - 1;
return IndexedStack(
index: safeIndex,
children: screens,
);
},
),
bottomNavigationBar: Builder(
builder: (context) {
final isProjectMode = ref.watch(projectModeProvider);
final isBusinessMode = ref.watch(businessModeProvider);
final List<BottomNavigationBarItem> navItems = [];
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'));
if (isProjectMode) navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.trello), label: 'Projects'));
if (isBusinessMode) navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.briefcase), label: 'Business'));
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'));
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'));
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'));
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'));
int safeIndex = _currentIndex;
if (safeIndex >= navItems.length) safeIndex = navItems.length - 1;
return BottomNavigationBar(
currentIndex: safeIndex,
type: BottomNavigationBarType.fixed,
onTap: (index) {
int addIdx = 1;
if (isProjectMode) addIdx++;
if (isBusinessMode) addIdx++;
addIdx++; // For Stats
if (index == addIdx) {
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen()));
} else {
setState(() {
_currentIndex = index;
});
}
},
selectedItemColor: Theme.of(context).colorScheme.primary,
unselectedItemColor: Colors.grey,
showSelectedLabels: true,
showUnselectedLabels: true,
items: navItems,
);
}
),
);"""
new_content = content[:old_start_idx] + "return Builder(builder: (context) {\n" + new_code + content[old_end_idx+6:]
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'w') as f:
f.write(new_content)
print("Dashboard screen patched")

145
scratch/patch_dashboard2.py Normal file
View File

@@ -0,0 +1,145 @@
import re
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'r') as f:
content = f.read()
# Add imports
imports_added = False
if "import '../../auth/providers/auth_provider.dart';" not in content:
content = content.replace(
"import '../../business/providers/indian_states_provider.dart';",
"import '../../business/providers/indian_states_provider.dart';\nimport '../../auth/providers/auth_provider.dart';\nimport '../../projects/presentation/projects_screen.dart';"
)
imports_added = True
# Add isProjectMode
if "final isProjectMode = ref.watch(projectModeProvider);" not in content:
content = content.replace(
"final isBusinessMode = ref.watch(businessModeProvider);",
"final isProjectMode = ref.watch(projectModeProvider);\n final isBusinessMode = ref.watch(businessModeProvider);"
)
# Fix Title logic
old_title_logic = """ String title;
if (_currentIndex == 0) {
title = 'Dashboard';
} else if (isBusinessMode) {
if (_currentIndex == 1) title = 'Business Hub';
else if (_currentIndex == 2) title = 'Statistics';
else if (_currentIndex == 3) title = 'My Accounts';
else title = 'Budgets';
} else {
if (_currentIndex == 1) title = 'Statistics';
else if (_currentIndex == 2) title = 'My Accounts';
else title = 'Budgets';
}"""
new_title_logic = """ String title = 'Dashboard';
int logicalIndex = _currentIndex;
if (logicalIndex == 0) title = 'Dashboard';
else {
if (isProjectMode) {
if (logicalIndex == 1) title = 'Projects';
logicalIndex--;
}
if (isBusinessMode) {
if (logicalIndex == 1) title = 'Business Hub';
logicalIndex--;
}
if (logicalIndex == 1) title = 'Statistics';
else if (logicalIndex == 2) title = 'My Accounts';
else if (logicalIndex == 3) title = 'Budgets';
}"""
content = content.replace(old_title_logic, new_title_logic)
home_start_idx = content.find("RefreshIndicator(\n onRefresh: _onRefresh,")
home_end_idx = content.find("// ---------------- BUSINESS TAB ----------------")
home_widget = content[home_start_idx:home_end_idx].strip()
if home_widget.endswith(","):
home_widget = home_widget[:-1]
stats_start_idx = content.find("StatisticsTab(")
stats_end_idx = content.find("// ---------------- WALLETS TAB ----------------")
stats_widget = content[stats_start_idx:stats_end_idx].strip()
if stats_widget.endswith(","):
stats_widget = stats_widget[:-1]
old_stack_start_idx = content.find("return IndexedStack(")
old_bottom_end_idx = content.find(" );\n }\n\n Widget _buildSummaryCard")
new_body_and_bottom = """ final List<Widget> screens = [];
screens.add(""" + home_widget + """);
if (isProjectMode) {
screens.add(const ProjectsScreen());
}
if (isBusinessMode) {
screens.add(const BusinessHubScreen());
}
screens.add(""" + stats_widget + """);
screens.add(const AccountsScreen());
screens.add(const BudgetScreen());
int safeIndex = _currentIndex;
if (safeIndex >= screens.length) safeIndex = screens.length - 1;
return IndexedStack(
index: safeIndex,
children: screens,
);
},
),
bottomNavigationBar: Builder(
builder: (context) {
final isProjectMode = ref.watch(projectModeProvider);
final isBusinessMode = ref.watch(businessModeProvider);
final List<BottomNavigationBarItem> navItems = [];
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'));
if (isProjectMode) navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.trello), label: 'Projects'));
if (isBusinessMode) navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.briefcase), label: 'Business'));
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'));
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'));
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'));
navItems.add(const BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'));
int safeIndex = _currentIndex;
// Adjust _currentIndex for the display of bottom nav bar because of 'Add' button
int addIdx = 1;
if (isProjectMode) addIdx++;
if (isBusinessMode) addIdx++;
addIdx++; // For Stats
int displayIndex = _currentIndex;
if (_currentIndex >= addIdx) displayIndex++;
if (displayIndex >= navItems.length) displayIndex = navItems.length - 1;
return BottomNavigationBar(
currentIndex: displayIndex,
type: BottomNavigationBarType.fixed,
onTap: (index) {
if (index == addIdx) {
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen()));
} else {
setState(() {
_currentIndex = index > addIdx ? index - 1 : index;
});
}
},
selectedItemColor: Theme.of(context).colorScheme.primary,
unselectedItemColor: Colors.grey,
showSelectedLabels: true,
showUnselectedLabels: true,
items: navItems,
);
}
),"""
content = content[:old_stack_start_idx] + new_body_and_bottom + content[old_bottom_end_idx:]
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'w') as f:
f.write(content)
print("Dashboard screen patched fully")

View File

@@ -0,0 +1,19 @@
import re
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'r') as f:
content = f.read()
content = content.replace("import '../../projects/presentation/projects_screen.dart';", "import '../../projects/presentation/project_hub_screen.dart';\nimport '../../projects/presentation/projects_screen.dart';")
old_projects_mode = r""" if \(isProjectMode\) {
screens\.add\(const ProjectsScreen\(\)\);
}"""
new_projects_mode = """ if (isProjectMode) {
screens.add(const ProjectHubScreen());
}"""
content = re.sub(old_projects_mode, new_projects_mode, content)
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'w') as f:
f.write(content)

View File

@@ -0,0 +1,9 @@
import re
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'r') as f:
content = f.read()
# We need to replace the `IndexedStack` and `BottomNavigationBar` with a simpler mapping
# But wait, building lists inside `build` is better.
# However, the `Scaffold` body contains the `IndexedStack`.

View File

@@ -0,0 +1,52 @@
import re
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'r') as f:
content = f.read()
# Make AddProjectSheet take a project
content = content.replace("class AddProjectSheet extends ConsumerStatefulWidget {\n const AddProjectSheet({super.key});", "class AddProjectSheet extends ConsumerStatefulWidget {\n final Project? project;\n const AddProjectSheet({super.key, this.project});")
content = content.replace(" String _name = '';\n String _description = '';\n int? _selectedCustomerId;\n double _budget = 0;", """ String _name = '';
String _description = '';
int? _selectedCustomerId;
double _budget = 0;
@override
void initState() {
super.initState();
if (widget.project != null) {
_name = widget.project!.name;
_description = widget.project!.description ?? '';
_selectedCustomerId = widget.project!.customerId;
_budget = widget.project!.budget ?? 0;
}
}""")
content = content.replace("labelText: 'Project Name',\n prefixIcon: const Icon(LucideIcons.folder),", "labelText: 'Project Name',\n initialValue: _name,\n prefixIcon: const Icon(LucideIcons.folder),")
content = content.replace("labelText: 'Description (Optional)',\n prefixIcon: const Icon(LucideIcons.alignLeft),", "labelText: 'Description (Optional)',\n initialValue: _description,\n prefixIcon: const Icon(LucideIcons.alignLeft),")
content = content.replace("labelText: 'Budget (Optional)',\n prefixIcon: const Icon(LucideIcons.indianRupee),", "labelText: 'Budget (Optional)',\n initialValue: _budget > 0 ? _budget.toString() : '',\n prefixIcon: const Icon(LucideIcons.indianRupee),")
content = content.replace("await repo.createProject(\n name: _name,\n description: _description.isNotEmpty ? _description : null,\n customerId: _selectedCustomerId,\n budget: _budget,\n );", """if (widget.project != null) {
await repo.updateProject(
id: widget.project!.id,
name: _name,
description: _description.isNotEmpty ? _description : null,
customerId: _selectedCustomerId,
budget: _budget,
status: widget.project!.status,
);
} else {
await repo.createProject(
name: _name,
description: _description.isNotEmpty ? _description : null,
customerId: _selectedCustomerId,
budget: _budget,
);
}""")
content = content.replace("child: _isLoading ? const CircularProgressIndicator(color: Colors.white) : const Text('Create Project', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),", "child: _isLoading ? const CircularProgressIndicator(color: Colors.white) : Text(widget.project == null ? 'Create Project' : 'Save Changes', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),")
content = content.replace("const Text('New Project', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),", "Text(widget.project == null ? 'New Project' : 'Edit Project', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),")
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'w') as f:
f.write(content)

View File

@@ -0,0 +1,39 @@
import re
with open('kifi-app/lib/features/projects/presentation/project_hub_screen.dart', 'r') as f:
content = f.read()
old_banner = r""" Container\(
padding: const EdgeInsets\.all\(16\),
decoration: BoxDecoration\(
color: Theme\.of\(context\)\.colorScheme\.primary\.withOpacity\(0\.1\),
borderRadius: BorderRadius\.circular\(16\),
border: Border\.all\(color: Theme\.of\(context\)\.colorScheme\.primary\.withOpacity\(0\.2\)\),
\),"""
new_banner = """ InkWell(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => const BusinessProfileFormSheet(),
);
},
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2)),
),"""
content = re.sub(old_banner, new_banner, content)
content = content.replace(" const SizedBox(height: 24),\n GridView.count(", " ),\n ),\n const SizedBox(height: 24),\n GridView.count(")
if "BusinessProfileFormSheet" not in content:
content = content.replace("import '../../vendor/presentation/vendors_list_screen.dart';", "import '../../vendor/presentation/vendors_list_screen.dart';\nimport '../../business/presentation/widgets/business_profile_form_sheet.dart';")
with open('kifi-app/lib/features/projects/presentation/project_hub_screen.dart', 'w') as f:
f.write(content)

10
scratch/patch_import.py Normal file
View File

@@ -0,0 +1,10 @@
import re
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'r') as f:
content = f.read()
if "add_customer_sheet.dart" not in content:
content = content.replace("import 'package:lucide_icons/lucide_icons.dart';", "import 'package:lucide_icons/lucide_icons.dart';\nimport '../../../sales/presentation/add_customer_sheet.dart';")
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'w') as f:
f.write(content)

14
scratch/patch_imports.py Normal file
View File

@@ -0,0 +1,14 @@
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'r') as f:
content = f.read()
imports_to_add = """
import '../../projects/presentation/projects_screen.dart';
import '../../projects/providers/project_mode_provider.dart';
"""
if "import '../../projects/presentation/projects_screen.dart';" not in content:
content = content.replace("import 'package:flutter/material.dart';", "import 'package:flutter/material.dart';" + imports_to_add)
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'w') as f:
f.write(content)
print("Dashboard imports patched")

View File

@@ -0,0 +1,33 @@
import re
with open('kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart', 'r') as f:
content = f.read()
# Read projectModeProvider to check if in Project Mode
if "project_mode_provider.dart" not in content:
content = content.replace("import '../../inventory/providers/products_provider.dart';", "import '../../inventory/providers/products_provider.dart';\nimport '../../projects/providers/project_mode_provider.dart';")
# In _buildAddLineItemDialog (inside showDialog builder)
# Oh wait, we need to read it inside the dialog, which has `Consumer(builder: (context, dialogRef, _))`
old_consumer = r""" Consumer\(
builder: \(context, dialogRef, _\) \{
final productsState = dialogRef\.watch\(productsProvider\);
return productsState\.when\("""
new_consumer = """ Consumer(
builder: (context, dialogRef, _) {
final isProjectMode = dialogRef.watch(projectModeProvider);
final productsState = dialogRef.watch(productsProvider);
// In project mode, skip product selector
if (isProjectMode) {
return const SizedBox.shrink();
}
return productsState.when("""
content = re.sub(old_consumer, new_consumer, content)
# Remove Making Chg and Other Chg fields if in project mode
# They are hardcoded as text fields. I can just wrap them in `if (!isProjectMode)` ... but `isProjectMode` isn't accessible directly unless we refactor the whole dialog.
# Since it's inside Consumer, the whole column is NOT inside the Consumer. The text fields are outside.

View File

@@ -0,0 +1,21 @@
import re
with open('kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart', 'r') as f:
content = f.read()
# Replace the wrong definition back to what it might have been... oh wait, my first script just modified `void _buildAddLineItemDialog` which didn't exist so it failed!
# Which means `content = content.replace(old_def, new_def)` did nothing, but `isProjectMode` was inserted manually? Wait! No, my script inserted `isProjectMode` but not `ref.read`!
# Ah, I replaced `Consumer(builder: (context, dialogRef, _))` with `if (!isProjectMode) Consumer(...)`. That's why `isProjectMode` is undefined!
def fix_it():
global content
# Let's just find `void _showAddItemDialog() {`
old_def = " void _showAddItemDialog() {"
new_def = " void _showAddItemDialog() {\n final isProjectMode = ref.read(projectModeProvider);"
content = content.replace(old_def, new_def)
fix_it()
with open('kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart', 'w') as f:
f.write(content)

View File

@@ -0,0 +1,11 @@
import re
with open('kifi-app/lib/core/widgets/premium_text_field.dart', 'r') as f:
content = f.read()
content = content.replace(" final TextEditingController? controller;", " final TextEditingController? controller;\n final FocusNode? focusNode;")
content = content.replace(" this.controller,", " this.controller,\n this.focusNode,")
content = content.replace(" controller: controller,", " controller: controller,\n focusNode: focusNode,")
with open('kifi-app/lib/core/widgets/premium_text_field.dart', 'w') as f:
f.write(content)

View File

@@ -0,0 +1,47 @@
import re
with open('kifi-api/src/main/java/com/kifi/api/controller/ProjectController.java', 'r') as f:
content = f.read()
# Add import
content = content.replace(
'import com.kifi.api.entity.project.ProjectTask;',
'import com.kifi.api.entity.project.ProjectTask;\nimport com.kifi.api.entity.project.ProjectTaskComment;'
)
# Add endpoints
endpoints = """
@GetMapping("/tasks/{taskId}/comments")
public Flux<ProjectTaskComment> getComments(@PathVariable Long taskId) {
return projectService.getCommentsForTask(taskId);
}
@PostMapping("/tasks/{taskId}/comments")
public Mono<ProjectTaskComment> addComment(
@PathVariable Long taskId,
@RequestBody CreateCommentRequest request,
Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return projectService.addComment(taskId, userId, request.getContent(), request.getImagesJson());
}
@Data
static class CreateProjectRequest {
"""
content = content.replace(' @Data\n static class CreateProjectRequest {', endpoints)
# Add request class
request_class = """
@Data
static class CreateCommentRequest {
private String content;
private String imagesJson;
}
}
"""
content = re.sub(r'}\s*$', request_class.strip() + '\n', content)
with open('kifi-api/src/main/java/com/kifi/api/controller/ProjectController.java', 'w') as f:
f.write(content)

View File

@@ -0,0 +1,42 @@
import re
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'r') as f:
content = f.read()
old_init = """class _AddProjectSheetState extends ConsumerState<AddProjectSheet> {
final _formKey = GlobalKey<FormState>();
String _name = '';
String _description = '';
double _budget = 0;
int? _selectedCustomerId;
bool _isLoading = false;
@override
Widget build(BuildContext context) {"""
new_init = """class _AddProjectSheetState extends ConsumerState<AddProjectSheet> {
final _formKey = GlobalKey<FormState>();
String _name = '';
String _description = '';
double _budget = 0;
int? _selectedCustomerId;
bool _isLoading = false;
@override
void initState() {
super.initState();
if (widget.project != null) {
_name = widget.project!.name;
_description = widget.project!.description ?? '';
_budget = widget.project!.budget ?? 0;
_selectedCustomerId = widget.project!.customerId;
}
}
@override
Widget build(BuildContext context) {"""
content = content.replace(old_init, new_init)
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'w') as f:
f.write(content)

View File

@@ -0,0 +1,43 @@
import re
with open('kifi-api/src/main/java/com/kifi/api/service/ProjectService.java', 'r') as f:
content = f.read()
# Add import
content = content.replace(
'import com.kifi.api.entity.project.ProjectTask;',
'import com.kifi.api.entity.project.ProjectTask;\nimport com.kifi.api.entity.project.ProjectTaskComment;\nimport com.kifi.api.repository.ProjectTaskCommentRepository;\nimport io.r2dbc.postgresql.codec.Json;'
)
# Add repository field
content = content.replace(
'private final ProjectTaskRepository projectTaskRepository;',
'private final ProjectTaskRepository projectTaskRepository;\n private final ProjectTaskCommentRepository projectTaskCommentRepository;'
)
# Add comment methods
methods = """
public Flux<ProjectTaskComment> getCommentsForTask(Long taskId) {
return projectTaskCommentRepository.findByTaskIdOrderByCreatedAtAsc(taskId);
}
public Mono<ProjectTaskComment> addComment(Long taskId, Long userId, String content, String imagesJson) {
ProjectTaskComment comment = new ProjectTaskComment();
comment.setTaskId(taskId);
comment.setUserId(userId);
comment.setContent(content);
if (imagesJson != null && !imagesJson.isEmpty()) {
comment.setImages(Json.of(imagesJson));
}
comment.setCreatedAt(LocalDateTime.now());
return projectTaskCommentRepository.save(comment);
}
}
"""
content = content.replace('}\n', methods, 1) # Note: this will replace the first `}\n` which might not be EOF if not careful, let's use regex for end of file
content = re.sub(r'}\s*$', methods.strip() + '\n}', content)
with open('kifi-api/src/main/java/com/kifi/api/service/ProjectService.java', 'w') as f:
f.write(content)

View File

@@ -0,0 +1,33 @@
import re
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'r') as f:
content = f.read()
# Replace SmartSearchDropdown with DropdownButtonFormField
dropdown = """ DropdownButtonFormField<int>(
value: _selectedCustomerId,
decoration: InputDecoration(
labelText: 'Client (Optional)',
prefixIcon: const Icon(LucideIcons.users),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
items: [
const DropdownMenuItem<int>(value: null, child: Text('None')),
...customersState.value!.map((c) => DropdownMenuItem<int>(
value: c.id,
child: Text(c.name),
)),
],
onChanged: (val) {
setState(() {
_selectedCustomerId = val;
});
},
),"""
content = re.sub(r' SmartSearchDropdown<Customer>\([\s\S]*?\),', dropdown, content)
content = content.replace("import '../../../core/widgets/smart_search_dropdown.dart';", "")
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'w') as f:
f.write(content)

View File

@@ -0,0 +1,10 @@
import re
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'r') as f:
content = f.read()
# Add filled: true, fillColor to DropdownButtonFormField
content = content.replace("border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),\n contentPadding:", "border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),\n filled: true,\n fillColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.05),\n contentPadding:")
with open('kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart', 'w') as f:
f.write(content)

View File

@@ -0,0 +1,11 @@
import re
with open('kifi-app/lib/features/projects/presentation/projects_screen.dart', 'r') as f:
content = f.read()
# Remove the AppBar section
app_bar_regex = r" appBar: AppBar\(\n title: const Text\('Projects \(Agency OS\)'\),\n backgroundColor: Colors\.white,\n foregroundColor: Colors\.black,\n elevation: 0,\n \),\n"
content = re.sub(app_bar_regex, "", content)
with open('kifi-app/lib/features/projects/presentation/projects_screen.dart', 'w') as f:
f.write(content)

View File

@@ -0,0 +1,42 @@
import re
with open('kifi-app/lib/features/projects/presentation/projects_screen.dart', 'r') as f:
content = f.read()
# Add AppBar
if "appBar: AppBar" not in content:
content = content.replace(" return Scaffold(\n body: projectsAsync.when(", """ return Scaffold(
appBar: AppBar(
title: const Text('Projects'),
backgroundColor: Colors.transparent,
elevation: 0,
),
body: projectsAsync.when(""")
# Add Edit button to ListTile
old_list_tile = r""" trailing: const Icon\(Icons\.chevron_right\),
onTap: \(\) \{
Navigator\.push\("""
new_list_tile = """ trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(LucideIcons.edit),
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => AddProjectSheet(project: p),
);
},
),
const Icon(Icons.chevron_right),
],
),
onTap: () {
Navigator.push("""
content = re.sub(old_list_tile, new_list_tile, content)
with open('kifi-app/lib/features/projects/presentation/projects_screen.dart', 'w') as f:
f.write(content)

22
scratch/patch_repo.py Normal file
View File

@@ -0,0 +1,22 @@
import re
with open('kifi-app/lib/features/projects/data/project_repository.dart', 'r') as f:
content = f.read()
new_get = """ Future<List<Project>> getProjects() async {
try {
final response = await _dio.get('/projects');
if (response.data == null) return [];
return (response.data as List).map((p) => Project.fromJson(p)).toList();
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw Exception("Projects API endpoint not found. Please ensure the backend server is updated to the latest Docker image.");
}
throw Exception(e.message ?? 'Unknown error fetching projects');
}
}"""
content = re.sub(r' Future<List<Project>> getProjects\(\) async \{[\s\S]*?\}', new_get, content)
with open('kifi-app/lib/features/projects/data/project_repository.dart', 'w') as f:
f.write(content)

13
scratch/patch_service2.py Normal file
View File

@@ -0,0 +1,13 @@
import re
with open('kifi-api/src/main/java/com/kifi/api/service/ProjectService.java', 'r') as f:
content = f.read()
# Remove Json import
content = content.replace('import io.r2dbc.postgresql.codec.Json;\n', '')
# Fix addComment logic
content = content.replace('comment.setImages(Json.of(imagesJson));', 'comment.setImages(imagesJson);')
with open('kifi-api/src/main/java/com/kifi/api/service/ProjectService.java', 'w') as f:
f.write(content)

View File

@@ -0,0 +1,39 @@
import re
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'r') as f:
content = f.read()
nav_logic = """
onGenerateInvoice: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => InvoiceBuilderScreen(
initialCustomerId: widget.project.customerId,
initialItems: [
InvoiceItem(
id: 0,
invoiceId: 0,
productId: null,
productName: t.title,
quantity: t.hoursLogged,
price: t.hourlyRate,
taxPercentage: 0,
)
],
),
),
);
},
"""
content = content.replace(' onGenerateInvoice: () {\n // TODO: Navigate to AddInvoiceScreen with task data\n },', nav_logic.strip())
# Add imports
content = content.replace(
'import \'widgets/task_card.dart\';',
'import \'widgets/task_card.dart\';\nimport \'../../sales/presentation/invoice_builder_screen.dart\';\nimport \'../../sales/domain/invoice.dart\';'
)
with open('kifi-app/lib/features/projects/presentation/project_board_screen.dart', 'w') as f:
f.write(content)

View File

@@ -0,0 +1,45 @@
import re
with open('kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart', 'r') as f:
content = f.read()
content = content.replace("import '../../providers/project_provider.dart';", "import '../../providers/project_provider.dart';\nimport '../../providers/user_provider.dart';\nimport '../../../../core/domain/user.dart';")
old_field = r""" PremiumTextField\(
labelText: 'Assignee Name \(Optional\)',
prefixIcon: const Icon\(LucideIcons\.user\),
onSaved: \(val\) => _assigneeName = val \?\? '',
\),"""
new_field = """ Autocomplete<User>(
optionsBuilder: (TextEditingValue textEditingValue) async {
if (textEditingValue.text.isEmpty) {
return const Iterable<User>.empty();
}
try {
return await ref.read(userSearchProvider(textEditingValue.text).future);
} catch (e) {
return const Iterable<User>.empty();
}
},
displayStringForOption: (User option) => '${option.name} (${option.email})',
onSelected: (User selection) {
_assigneeName = selection.name;
},
fieldViewBuilder: (context, textEditingController, focusNode, onFieldSubmitted) {
return PremiumTextField(
controller: textEditingController,
focusNode: focusNode,
labelText: 'Assignee Name (Optional)',
prefixIcon: const Icon(LucideIcons.user),
onSaved: (val) {
if (_assigneeName.isEmpty) _assigneeName = val ?? '';
},
);
},
),"""
content = re.sub(old_field, new_field, content)
with open('kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart', 'w') as f:
f.write(content)

39
scratch/patch_title.py Normal file
View File

@@ -0,0 +1,39 @@
import re
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'r') as f:
content = f.read()
replacement = """
String title = '';
int logicalIndex = _currentIndex;
if (_currentIndex == 0) {
title = 'Dashboard';
} else {
if (isProjectMode) {
if (logicalIndex == 1) title = 'Projects';
logicalIndex--;
}
if (title.isEmpty) {
if (isBusinessMode) {
if (logicalIndex == 1) title = 'Business Hub';
else if (logicalIndex == 2) title = 'Statistics';
else if (logicalIndex == 3) title = 'My Accounts';
else title = 'Budgets';
} else {
if (logicalIndex == 1) title = 'Statistics';
else if (logicalIndex == 2) title = 'My Accounts';
else title = 'Budgets';
}
}
}
"""
content = re.sub(
r'String title;\n int logicalIndex = _currentIndex;.*?else title = \'Budgets\';\n }\n }\n }',
replacement.strip(),
content,
flags=re.DOTALL
)
with open('kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart', 'w') as f:
f.write(content)

View File

@@ -0,0 +1,10 @@
import re
with open('kifi-app/lib/features/projects/providers/user_provider.dart', 'r') as f:
content = f.read()
content = content.replace("import '../../core/network/dio_client.dart';", "import '../../../../core/network/dio_client.dart';")
content = content.replace("import '../../core/domain/user.dart';", "import '../../../../core/domain/user.dart';")
with open('kifi-app/lib/features/projects/providers/user_provider.dart', 'w') as f:
f.write(content)