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,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()
: dio = Dio(BaseOptions(
baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2',
//baseUrl: 'http://192.168.0.104:8080/api/kifi-v2', // Local Mac IP for physical device testing
//baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2',
baseUrl: 'http://192.168.0.104:8080/api/kifi-v2', // Local Mac IP for physical device testing
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
)),

View File

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

View File

@@ -13,6 +13,7 @@ import '../../transactions/providers/providers.dart';
import '../../../core/theme/theme_provider.dart';
import '../../business/providers/business_mode_provider.dart';
import '../../projects/providers/project_mode_provider.dart';
import '../../business/presentation/settings/business_settings_screen.dart';
class ProfileScreen extends ConsumerStatefulWidget {
@@ -141,7 +142,22 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
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 'package:flutter/material.dart';
import '../../projects/presentation/project_hub_screen.dart';
import '../../projects/presentation/projects_screen.dart';
import '../../projects/providers/project_mode_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
@@ -150,6 +154,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final walletsState = ref.watch(walletProvider);
final invoicesState = ref.watch(invoicesProvider);
final customersState = ref.watch(customersProvider);
final isProjectMode = ref.watch(projectModeProvider);
final isBusinessMode = ref.watch(businessModeProvider);
final allTransactions = transState.value ?? [];
@@ -157,18 +162,21 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final startDate = range.start;
final endDate = range.end;
String title;
if (_currentIndex == 0) {
title = 'Dashboard';
} else if (isBusinessMode) {
if (_currentIndex == 1) title = 'Business Hub';
else if (_currentIndex == 2) title = 'Statistics';
else if (_currentIndex == 3) title = 'My Accounts';
else title = 'Budgets';
} else {
if (_currentIndex == 1) title = 'Statistics';
else if (_currentIndex == 2) title = 'My Accounts';
else title = 'Budgets';
String title = 'Dashboard';
int logicalIndex = _currentIndex;
if (logicalIndex == 0) title = 'Dashboard';
else {
if (isProjectMode) {
if (logicalIndex == 1) title = 'Projects';
logicalIndex--;
}
if (isBusinessMode) {
if (logicalIndex == 1) title = 'Business Hub';
logicalIndex--;
}
if (logicalIndex == 1) title = 'Statistics';
else if (logicalIndex == 2) title = 'My Accounts';
else if (logicalIndex == 3) title = 'Budgets';
}
return Scaffold(
@@ -400,11 +408,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
});
}
return IndexedStack(
index: _currentIndex,
children: [
// ---------------- HOME TAB ----------------
RefreshIndicator(
final List<Widget> screens = [];
screens.add(RefreshIndicator(
onRefresh: _onRefresh,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
@@ -624,13 +630,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
],
),
),
),
));
// ---------------- BUSINESS TAB ----------------
if (isBusinessMode) const BusinessHubScreen(),
if (isProjectMode) {
screens.add(const ProjectHubScreen());
}
// ---------------- STATS TAB ----------------
StatisticsTab(
if (isBusinessMode) {
screens.add(const BusinessHubScreen());
}
screens.add(StatisticsTab(
transactions: transactions,
wallets: safeWallets,
categories: categoriesState.value ?? [],
@@ -643,47 +653,64 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
},
onPickCustomDateRange: _pickCustomDateRange,
onRefresh: _onRefresh,
),
// ---------------- WALLETS TAB ----------------
const AccountsScreen(),
// ---------------- BUDGETS TAB ----------------
const BudgetScreen(),
],
));
screens.add(const AccountsScreen());
screens.add(const BudgetScreen());
int safeIndex = _currentIndex;
if (safeIndex >= screens.length) safeIndex = screens.length - 1;
return IndexedStack(
index: safeIndex,
children: screens,
);
},
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: isBusinessMode
? (_currentIndex >= 3 ? _currentIndex + 1 : _currentIndex)
: (_currentIndex >= 2 ? _currentIndex + 1 : _currentIndex),
type: BottomNavigationBarType.fixed,
onTap: (index) {
final addIndex = isBusinessMode ? 3 : 2;
if (index == addIndex) {
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen()));
} else {
setState(() {
_currentIndex = index > addIndex ? index - 1 : index;
});
}
},
selectedItemColor: Theme.of(context).colorScheme.primary,
unselectedItemColor: Colors.grey,
showSelectedLabels: true,
showUnselectedLabels: true,
items: [
const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'),
if (isBusinessMode)
const BottomNavigationBarItem(icon: Icon(LucideIcons.briefcase), label: 'Business'),
const BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'),
const BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'),
const BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'),
const BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'),
],
),
);
bottomNavigationBar: Builder(
builder: (context) {
final isProjectMode = ref.watch(projectModeProvider);
final isBusinessMode = ref.watch(businessModeProvider);
final List<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,
);
}
), );
}
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 '../domain/customer.dart';
import '../../inventory/providers/products_provider.dart';
import '../../projects/providers/project_mode_provider.dart';
import '../../inventory/domain/product.dart';
import 'add_customer_sheet.dart';
import '../../transactions/providers/providers.dart';
class InvoiceBuilderScreen extends ConsumerStatefulWidget {
const InvoiceBuilderScreen({super.key});
final List<InvoiceItem>? initialItems;
final int? initialCustomerId;
const InvoiceBuilderScreen({super.key, this.initialItems, this.initialCustomerId});
@override
ConsumerState<InvoiceBuilderScreen> createState() => _InvoiceBuilderScreenState();
@@ -27,6 +31,29 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
Customer? _selectedCustomer;
final List<InvoiceItem> _items = [];
bool _isEmi = false;
@override
void initState() {
super.initState();
if (widget.initialItems != null) {
_items.addAll(widget.initialItems!);
}
}
void _loadInitialCustomer() {
if (widget.initialCustomerId != null && _selectedCustomer == null) {
final customers = ref.read(customersProvider).value;
if (customers != null) {
final cust = customers.where((c) => c.id == widget.initialCustomerId).firstOrNull;
if (cust != null) {
setState(() {
_selectedCustomer = cust;
});
}
}
}
}
String _emiCycle = 'MONTHLY';
final TextEditingController _emiAmountCtrl = TextEditingController();
final TextEditingController _invoiceNumberCtrl = TextEditingController(text: 'INV-${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}');
@@ -104,6 +131,7 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
}
void _showAddItemDialog() {
final isProjectMode = ref.read(projectModeProvider);
Product? selectedProduct;
final TextEditingController descCtrl = TextEditingController();
final TextEditingController qtyCtrl = TextEditingController(text: '1');
@@ -126,7 +154,7 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Consumer(
if (!isProjectMode) Consumer(
builder: (context, dialogRef, _) {
final productsState = dialogRef.watch(productsProvider);
return productsState.when(
@@ -222,14 +250,16 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: PremiumTextField(controller: makingCtrl, labelText: 'Making Chg', keyboardType: TextInputType.number)),
const SizedBox(width: 8),
Expanded(child: PremiumTextField(controller: otherCtrl, labelText: 'Other Chg', keyboardType: TextInputType.number)),
],
),
if (!isProjectMode) ...[
const SizedBox(height: 12),
Row(
children: [
Expanded(child: PremiumTextField(controller: makingCtrl, labelText: 'Making Chg', keyboardType: TextInputType.number)),
const SizedBox(width: 8),
Expanded(child: PremiumTextField(controller: otherCtrl, labelText: 'Other Chg', keyboardType: TextInputType.number)),
],
),
],
],
),
),
@@ -332,6 +362,7 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
@override
Widget build(BuildContext context) {
_loadInitialCustomer();
final customersState = ref.watch(customersProvider);
final formatCurrency = NumberFormat.currency(symbol: '');

View File

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

View File

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