Project management related changes
This commit is contained in:
15
kifi-app/lib/core/domain/user.dart
Normal file
15
kifi-app/lib/core/domain/user.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
class User {
|
||||
final int id;
|
||||
final String name;
|
||||
final String email;
|
||||
|
||||
User({required this.id, required this.name, required this.email});
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> json) {
|
||||
return User(
|
||||
id: json['id'],
|
||||
name: json['name'] ?? '',
|
||||
email: json['email'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ class DioClient {
|
||||
|
||||
DioClient._internal()
|
||||
: 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),
|
||||
)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}) {
|
||||
|
||||
131
kifi-app/lib/features/projects/data/project_repository.dart
Normal file
131
kifi-app/lib/features/projects/data/project_repository.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../domain/project.dart';
|
||||
import '../domain/project_task.dart';
|
||||
import 'dart:convert';
|
||||
import '../domain/project_task_comment.dart';
|
||||
|
||||
class ProjectRepository {
|
||||
final Dio _dio;
|
||||
|
||||
ProjectRepository(this._dio);
|
||||
|
||||
Future<List<Project>> getProjects() async {
|
||||
try {
|
||||
final response = await _dio.get('/projects');
|
||||
if (response.data == null) return [];
|
||||
return (response.data as List).map((p) => Project.fromJson(p)).toList();
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) {
|
||||
throw Exception("Projects API endpoint not found. Please ensure the backend server is updated to the latest Docker image.");
|
||||
}
|
||||
throw Exception(e.message ?? 'Unknown error fetching projects');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Project> createProject({
|
||||
required String name,
|
||||
String? description,
|
||||
int? customerId,
|
||||
double? budget,
|
||||
}) async {
|
||||
final response = await _dio.post('/projects', data: {
|
||||
'name': name,
|
||||
if (description != null) 'description': description,
|
||||
if (customerId != null) 'customerId': customerId,
|
||||
if (budget != null) 'budget': budget,
|
||||
});
|
||||
return Project.fromJson(response.data);
|
||||
}
|
||||
|
||||
Future<Project> updateProject({
|
||||
required int projectId,
|
||||
String? name,
|
||||
String? description,
|
||||
int? customerId,
|
||||
double? budget,
|
||||
String? status,
|
||||
}) async {
|
||||
final response = await _dio.put('/projects/$projectId', data: {
|
||||
if (name != null) 'name': name,
|
||||
if (description != null) 'description': description,
|
||||
if (customerId != null) 'customerId': customerId,
|
||||
if (budget != null) 'budget': budget,
|
||||
if (status != null) 'status': status,
|
||||
});
|
||||
return Project.fromJson(response.data);
|
||||
}
|
||||
|
||||
Future<void> deleteProject(int projectId) async {
|
||||
await _dio.delete('/projects/$projectId');
|
||||
}
|
||||
|
||||
Future<List<ProjectTask>> getTasks(int projectId) async {
|
||||
final response = await _dio.get('/projects/$projectId/tasks');
|
||||
return (response.data as List).map((t) => ProjectTask.fromJson(t)).toList();
|
||||
}
|
||||
|
||||
Future<ProjectTask> createTask({
|
||||
required int projectId,
|
||||
required String title,
|
||||
String? description,
|
||||
String? assigneeName,
|
||||
int? assigneeUserId,
|
||||
bool? isBillable,
|
||||
double? hourlyRate,
|
||||
}) async {
|
||||
final response = await _dio.post('/projects/$projectId/tasks', data: {
|
||||
'title': title,
|
||||
if (description != null) 'description': description,
|
||||
if (assigneeName != null) 'assigneeName': assigneeName,
|
||||
if (assigneeUserId != null) 'assigneeUserId': assigneeUserId,
|
||||
if (isBillable != null) 'isBillable': isBillable,
|
||||
if (hourlyRate != null) 'hourlyRate': hourlyRate,
|
||||
});
|
||||
return ProjectTask.fromJson(response.data);
|
||||
}
|
||||
|
||||
Future<ProjectTask> updateTask({
|
||||
required int taskId,
|
||||
String? title,
|
||||
String? description,
|
||||
String? assigneeName,
|
||||
int? assigneeUserId,
|
||||
String? status,
|
||||
bool? isBillable,
|
||||
double? hourlyRate,
|
||||
double? hoursLogged,
|
||||
}) async {
|
||||
final response = await _dio.put('/projects/tasks/$taskId', data: {
|
||||
if (title != null) 'title': title,
|
||||
if (description != null) 'description': description,
|
||||
if (assigneeName != null) 'assigneeName': assigneeName,
|
||||
if (assigneeUserId != null) 'assigneeUserId': assigneeUserId,
|
||||
if (status != null) 'status': status,
|
||||
if (isBillable != null) 'isBillable': isBillable,
|
||||
if (hourlyRate != null) 'hourlyRate': hourlyRate,
|
||||
if (hoursLogged != null) 'hoursLogged': hoursLogged,
|
||||
});
|
||||
return ProjectTask.fromJson(response.data);
|
||||
}
|
||||
|
||||
Future<void> deleteTask(int taskId) async {
|
||||
await _dio.delete('/projects/tasks/$taskId');
|
||||
}
|
||||
Future<List<ProjectTaskComment>> getComments(int taskId) async {
|
||||
final response = await _dio.get('/projects/tasks/$taskId/comments');
|
||||
return (response.data as List).map((c) => ProjectTaskComment.fromJson(c)).toList();
|
||||
}
|
||||
|
||||
Future<ProjectTaskComment> addComment({
|
||||
required int taskId,
|
||||
required String content,
|
||||
List<String>? imagesBase64,
|
||||
}) async {
|
||||
final response = await _dio.post('/projects/tasks/$taskId/comments', data: {
|
||||
'content': content,
|
||||
if (imagesBase64 != null && imagesBase64.isNotEmpty)
|
||||
'imagesJson': jsonEncode(imagesBase64),
|
||||
});
|
||||
return ProjectTaskComment.fromJson(response.data);
|
||||
}
|
||||
}
|
||||
47
kifi-app/lib/features/projects/domain/project.dart
Normal file
47
kifi-app/lib/features/projects/domain/project.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
class Project {
|
||||
final int id;
|
||||
final String name;
|
||||
final String? description;
|
||||
final int? customerId;
|
||||
final int? userId;
|
||||
final double budget;
|
||||
final String status;
|
||||
final DateTime? createdAt;
|
||||
|
||||
Project({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.description,
|
||||
this.customerId,
|
||||
this.userId,
|
||||
this.budget = 0.0,
|
||||
this.status = 'ACTIVE',
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory Project.fromJson(Map<String, dynamic> json) {
|
||||
return Project(
|
||||
id: json['id'] as int,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String?,
|
||||
customerId: json['customerId'] as int?,
|
||||
userId: json['userId'] as int?,
|
||||
budget: (json['budget'] as num?)?.toDouble() ?? 0.0,
|
||||
status: json['status'] as String? ?? 'ACTIVE',
|
||||
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'customerId': customerId,
|
||||
'userId': userId,
|
||||
'budget': budget,
|
||||
'status': status,
|
||||
'createdAt': createdAt?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
59
kifi-app/lib/features/projects/domain/project_task.dart
Normal file
59
kifi-app/lib/features/projects/domain/project_task.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
class ProjectTask {
|
||||
final int id;
|
||||
final int projectId;
|
||||
final String title;
|
||||
final String? description;
|
||||
final String? assigneeName;
|
||||
final int? assigneeUserId;
|
||||
final String status;
|
||||
final bool isBillable;
|
||||
final double hourlyRate;
|
||||
final double hoursLogged;
|
||||
final DateTime? createdAt;
|
||||
|
||||
ProjectTask({
|
||||
required this.id,
|
||||
required this.projectId,
|
||||
required this.title,
|
||||
this.description,
|
||||
this.assigneeName,
|
||||
this.assigneeUserId,
|
||||
this.status = 'TODO',
|
||||
this.isBillable = false,
|
||||
this.hourlyRate = 0.0,
|
||||
this.hoursLogged = 0.0,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory ProjectTask.fromJson(Map<String, dynamic> json) {
|
||||
return ProjectTask(
|
||||
id: json['id'] as int,
|
||||
projectId: json['projectId'] as int,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String?,
|
||||
assigneeName: json['assigneeName'] as String?,
|
||||
assigneeUserId: json['assigneeUserId'] as int?,
|
||||
status: json['status'] as String? ?? 'TODO',
|
||||
isBillable: json['isBillable'] as bool? ?? false,
|
||||
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||
hoursLogged: (json['hoursLogged'] as num?)?.toDouble() ?? 0.0,
|
||||
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'projectId': projectId,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'assigneeName': assigneeName,
|
||||
'assigneeUserId': assigneeUserId,
|
||||
'status': status,
|
||||
'isBillable': isBillable,
|
||||
'hourlyRate': hourlyRate,
|
||||
'hoursLogged': hoursLogged,
|
||||
'createdAt': createdAt?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class ProjectTaskComment {
|
||||
final int id;
|
||||
final int taskId;
|
||||
final int userId;
|
||||
final String content;
|
||||
final List<String> images;
|
||||
final DateTime createdAt;
|
||||
|
||||
ProjectTaskComment({
|
||||
required this.id,
|
||||
required this.taskId,
|
||||
required this.userId,
|
||||
required this.content,
|
||||
required this.images,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory ProjectTaskComment.fromJson(Map<String, dynamic> json) {
|
||||
List<String> parsedImages = [];
|
||||
if (json['images'] != null) {
|
||||
if (json['images'] is String) {
|
||||
try {
|
||||
parsedImages = List<String>.from(jsonDecode(json['images']));
|
||||
} catch (_) {}
|
||||
} else if (json['images'] is List) {
|
||||
parsedImages = List<String>.from(json['images']);
|
||||
}
|
||||
}
|
||||
|
||||
return ProjectTaskComment(
|
||||
id: json['id'] as int,
|
||||
taskId: json['taskId'] as int,
|
||||
userId: json['userId'] as int,
|
||||
content: json['content'] as String,
|
||||
images: parsedImages,
|
||||
createdAt: DateTime.parse(json['createdAt']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../domain/project.dart';
|
||||
import '../providers/project_provider.dart';
|
||||
import 'widgets/add_task_sheet.dart';
|
||||
import 'widgets/task_card.dart';
|
||||
import 'widgets/add_project_sheet.dart';
|
||||
import '../../sales/presentation/invoice_builder_screen.dart';
|
||||
import '../../sales/domain/invoice.dart';
|
||||
|
||||
class ProjectBoardScreen extends ConsumerStatefulWidget {
|
||||
final Project project;
|
||||
|
||||
const ProjectBoardScreen({super.key, required this.project});
|
||||
|
||||
@override
|
||||
ConsumerState<ProjectBoardScreen> createState() => _ProjectBoardScreenState();
|
||||
}
|
||||
|
||||
class _ProjectBoardScreenState extends ConsumerState<ProjectBoardScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tasksAsync = ref.watch(projectTasksProvider(widget.project.id));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[100],
|
||||
appBar: AppBar(
|
||||
title: Text(widget.project.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.info),
|
||||
tooltip: 'Project Details',
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => AddProjectSheet(project: widget.project),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: tasksAsync.when(
|
||||
data: (tasks) {
|
||||
final columns = [
|
||||
_ColumnDef('To Do', 'TODO', Colors.blueGrey, LucideIcons.circle),
|
||||
_ColumnDef('In Progress', 'IN_PROGRESS', Colors.blue, LucideIcons.loader),
|
||||
_ColumnDef('Review', 'REVIEW', Colors.orange, LucideIcons.eye),
|
||||
_ColumnDef('Done', 'DONE', Colors.green, LucideIcons.checkCircle),
|
||||
];
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: columns.map((col) {
|
||||
final columnTasks = tasks.where((t) => t.status == col.status).toList();
|
||||
return _buildColumn(col, columnTasks, showAdd: col.status == 'TODO');
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColumn(_ColumnDef col, List tasks, {bool showAdd = false}) {
|
||||
return Container(
|
||||
width: 290,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: col.color.withValues(alpha: 0.04),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: col.color.withValues(alpha: 0.12)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Column header
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: col.color.withValues(alpha: 0.08),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(col.icon, size: 16, color: col.color),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
col.label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: col.color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: col.color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'${tasks.length}',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: col.color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Task cards
|
||||
...tasks.map((t) => TaskCard(
|
||||
task: t,
|
||||
onEdit: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => AddTaskSheet(
|
||||
projectId: widget.project.id,
|
||||
task: t,
|
||||
),
|
||||
);
|
||||
},
|
||||
onGenerateInvoice: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => InvoiceBuilderScreen(
|
||||
initialCustomerId: widget.project.customerId,
|
||||
initialItems: [
|
||||
InvoiceItem(
|
||||
id: 0,
|
||||
invoiceId: 0,
|
||||
productId: null,
|
||||
description: t.title,
|
||||
quantity: t.hoursLogged,
|
||||
unitPrice: t.hourlyRate,
|
||||
total: t.hoursLogged * t.hourlyRate,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)),
|
||||
|
||||
if (showAdd)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: col.color,
|
||||
side: BorderSide(color: col.color.withValues(alpha: 0.3)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
),
|
||||
icon: const Icon(LucideIcons.plus, size: 16),
|
||||
label: const Text('Add Task', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => AddTaskSheet(projectId: widget.project.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColumnDef {
|
||||
final String label;
|
||||
final String status;
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
|
||||
const _ColumnDef(this.label, this.status, this.color, this.icon);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../../core/theme/nature_colors.dart';
|
||||
import 'projects_screen.dart';
|
||||
import '../../sales/presentation/invoices_list_screen.dart';
|
||||
import '../../sales/presentation/customers_list_screen.dart';
|
||||
import '../../vendor/presentation/vendors_list_screen.dart';
|
||||
import '../../business/presentation/widgets/business_profile_form_sheet.dart';
|
||||
|
||||
class ProjectHubScreen extends ConsumerWidget {
|
||||
const ProjectHubScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => const BusinessProfileFormSheet(),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.layoutDashboard, color: Theme.of(context).colorScheme.primary, size: 32),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Agency OS', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const Text('Manage projects, tasks, and client billing', style: TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
GridView.count(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
childAspectRatio: 1.2,
|
||||
children: [
|
||||
_buildActionCard(
|
||||
context,
|
||||
'Projects & Tasks',
|
||||
'Manage all projects',
|
||||
LucideIcons.folderKanban,
|
||||
Colors.blue,
|
||||
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const ProjectsScreen())),
|
||||
),
|
||||
_buildActionCard(
|
||||
context,
|
||||
'Invoices',
|
||||
'Client billing',
|
||||
LucideIcons.receipt,
|
||||
Colors.green,
|
||||
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const InvoicesListScreen())),
|
||||
),
|
||||
_buildActionCard(
|
||||
context,
|
||||
'Clients',
|
||||
'Manage clients',
|
||||
LucideIcons.users,
|
||||
NatureColors.getColor('RECEIVABLES'),
|
||||
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomersListScreen())),
|
||||
),
|
||||
_buildActionCard(
|
||||
context,
|
||||
'Contractors',
|
||||
'Manage vendors',
|
||||
LucideIcons.truck,
|
||||
Colors.orange,
|
||||
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const VendorsListScreen())),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionCard(BuildContext context, String title, String subtitle, IconData icon, Color color, VoidCallback onTap) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: color.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 28),
|
||||
const SizedBox(height: 12),
|
||||
Text(title, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitle, style: TextStyle(color: color.withValues(alpha: 0.7), fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
133
kifi-app/lib/features/projects/presentation/projects_screen.dart
Normal file
133
kifi-app/lib/features/projects/presentation/projects_screen.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/project_provider.dart';
|
||||
import 'project_board_screen.dart';
|
||||
import 'widgets/add_project_sheet.dart';
|
||||
|
||||
class ProjectsScreen extends ConsumerStatefulWidget {
|
||||
const ProjectsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProjectsScreen> createState() => _ProjectsScreenState();
|
||||
}
|
||||
|
||||
class _ProjectsScreenState extends ConsumerState<ProjectsScreen> {
|
||||
String _searchQuery = "";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectsAsync = ref.watch(projectsProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[100],
|
||||
appBar: AppBar(
|
||||
title: const Text('Projects', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search projects...',
|
||||
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
|
||||
filled: true,
|
||||
fillColor: Colors.grey[100],
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
onChanged: (val) {
|
||||
setState(() => _searchQuery = val);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: projectsAsync.when(
|
||||
data: (projects) {
|
||||
final filteredProjects = projects.where((p) => p.name.toLowerCase().contains(_searchQuery.toLowerCase())).toList();
|
||||
if (filteredProjects.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.folderKanban, size: 48, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 12),
|
||||
Text('No projects found.', style: TextStyle(color: Colors.grey.shade500)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: filteredProjects.length,
|
||||
itemBuilder: (context, index) {
|
||||
final p = filteredProjects[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(p.description ?? 'No description', style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(LucideIcons.edit, size: 18, color: Colors.grey.shade600),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => AddProjectSheet(project: p),
|
||||
);
|
||||
},
|
||||
),
|
||||
Icon(Icons.chevron_right, color: Colors.grey.shade400),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ProjectBoardScreen(project: p),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => const AddProjectSheet(),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Project'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../domain/project.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../sales/presentation/add_customer_sheet.dart';
|
||||
import '../../providers/project_provider.dart';
|
||||
import '../../../sales/providers/customers_provider.dart';
|
||||
import '../../../../core/widgets/premium_text_field.dart';
|
||||
|
||||
class AddProjectSheet extends ConsumerStatefulWidget {
|
||||
final Project? project;
|
||||
const AddProjectSheet({super.key, this.project});
|
||||
|
||||
@override
|
||||
ConsumerState<AddProjectSheet> createState() => _AddProjectSheetState();
|
||||
}
|
||||
|
||||
class _AddProjectSheetState extends ConsumerState<AddProjectSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
String _name = '';
|
||||
String _description = '';
|
||||
double _budget = 0;
|
||||
int? _selectedCustomerId;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.project != null) {
|
||||
_name = widget.project!.name;
|
||||
_description = widget.project!.description ?? '';
|
||||
_budget = widget.project!.budget ?? 0;
|
||||
_selectedCustomerId = widget.project!.customerId;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final customersState = ref.watch(customersProvider);
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
left: 24,
|
||||
right: 24,
|
||||
top: 24,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(widget.project == null ? 'New Project' : 'Edit Project', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PremiumTextField(
|
||||
labelText: 'Project Name',
|
||||
initialValue: _name,
|
||||
prefixIcon: const Icon(LucideIcons.folder),
|
||||
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
|
||||
onSaved: (val) => _name = val!,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PremiumTextField(
|
||||
maxLines: 3,
|
||||
labelText: 'Description (Optional)',
|
||||
initialValue: _description,
|
||||
prefixIcon: const Icon(LucideIcons.alignLeft),
|
||||
onSaved: (val) => _description = val ?? '',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (customersState.hasValue)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<int>(
|
||||
value: _selectedCustomerId,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Client (Optional)',
|
||||
prefixIcon: const Icon(LucideIcons.users),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.05),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem<int>(value: null, child: Text('None')),
|
||||
...customersState.value!.map((c) => DropdownMenuItem<int>(
|
||||
value: c.id,
|
||||
child: Text(c.name),
|
||||
)),
|
||||
],
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_selectedCustomerId = val;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
height: 52, // Match the height of the dropdown approximately
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
tooltip: 'Add New Client',
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => const AddCustomerSheet(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PremiumTextField(
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
labelText: 'Budget (Optional)',
|
||||
initialValue: _budget > 0 ? _budget.toString() : '',
|
||||
prefixIcon: const Icon(LucideIcons.indianRupee),
|
||||
onSaved: (val) => _budget = double.tryParse(val ?? '0') ?? 0,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: _isLoading ? null : _submit,
|
||||
child: _isLoading ? const CircularProgressIndicator(color: Colors.white) : Text(widget.project == null ? 'Create Project' : 'Save Changes', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
_formKey.currentState!.save();
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final repo = ref.read(projectRepositoryProvider);
|
||||
if (widget.project != null) {
|
||||
await repo.updateProject(
|
||||
projectId: widget.project!.id,
|
||||
name: _name,
|
||||
description: _description.isNotEmpty ? _description : null,
|
||||
customerId: _selectedCustomerId,
|
||||
budget: _budget,
|
||||
status: widget.project!.status,
|
||||
);
|
||||
} else {
|
||||
await repo.createProject(
|
||||
name: _name,
|
||||
description: _description.isNotEmpty ? _description : null,
|
||||
customerId: _selectedCustomerId,
|
||||
budget: _budget,
|
||||
);
|
||||
}
|
||||
ref.invalidate(projectsProvider);
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/widgets/premium_text_field.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../domain/project_task.dart';
|
||||
import '../../providers/project_provider.dart';
|
||||
import '../../providers/user_provider.dart';
|
||||
import '../../../../core/domain/user.dart';
|
||||
|
||||
class AddTaskSheet extends ConsumerStatefulWidget {
|
||||
final int projectId;
|
||||
final ProjectTask? task;
|
||||
|
||||
const AddTaskSheet({super.key, required this.projectId, this.task});
|
||||
|
||||
@override
|
||||
ConsumerState<AddTaskSheet> createState() => _AddTaskSheetState();
|
||||
}
|
||||
|
||||
class _AddTaskSheetState extends ConsumerState<AddTaskSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
String _title = '';
|
||||
String _description = '';
|
||||
String _assigneeName = '';
|
||||
bool _isBillable = false;
|
||||
double _hourlyRate = 0;
|
||||
bool _isLoading = false;
|
||||
|
||||
bool get _isEditing => widget.task != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.task != null) {
|
||||
_title = widget.task!.title;
|
||||
_description = widget.task!.description ?? '';
|
||||
_assigneeName = widget.task!.assigneeName ?? '';
|
||||
_isBillable = widget.task!.isBillable;
|
||||
_hourlyRate = widget.task!.hourlyRate;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
left: 24,
|
||||
right: 24,
|
||||
top: 24,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(_isEditing ? 'Edit Task' : 'New Task', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PremiumTextField(
|
||||
labelText: 'Task Title',
|
||||
initialValue: _isEditing ? _title : null,
|
||||
prefixIcon: const Icon(LucideIcons.checkSquare),
|
||||
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
|
||||
onSaved: (val) => _title = val!,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PremiumTextField(
|
||||
maxLines: 3,
|
||||
labelText: 'Description (Optional)',
|
||||
initialValue: _isEditing ? _description : null,
|
||||
prefixIcon: const Icon(LucideIcons.alignLeft),
|
||||
onSaved: (val) => _description = val ?? '',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Autocomplete<User>(
|
||||
initialValue: _isEditing && _assigneeName.isNotEmpty
|
||||
? TextEditingValue(text: _assigneeName)
|
||||
: null,
|
||||
optionsBuilder: (TextEditingValue textEditingValue) async {
|
||||
if (textEditingValue.text.isEmpty) {
|
||||
return const Iterable<User>.empty();
|
||||
}
|
||||
try {
|
||||
return await ref.read(userSearchProvider(textEditingValue.text).future);
|
||||
} catch (e) {
|
||||
return const Iterable<User>.empty();
|
||||
}
|
||||
},
|
||||
displayStringForOption: (User option) => '${option.name} (${option.email})',
|
||||
onSelected: (User selection) {
|
||||
_assigneeName = selection.name;
|
||||
},
|
||||
optionsViewBuilder: (context, onSelected, options) {
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: true,
|
||||
itemCount: options.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final User option = options.elementAt(index);
|
||||
return ListTile(
|
||||
title: Text(option.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(option.email, style: const TextStyle(color: Colors.grey)),
|
||||
onTap: () => onSelected(option),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
fieldViewBuilder: (context, textEditingController, focusNode, onFieldSubmitted) {
|
||||
return PremiumTextField(
|
||||
controller: textEditingController,
|
||||
focusNode: focusNode,
|
||||
labelText: 'Assignee Name (Optional)',
|
||||
prefixIcon: const Icon(LucideIcons.user),
|
||||
onSaved: (val) {
|
||||
if (_assigneeName.isEmpty) _assigneeName = val ?? '';
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
title: const Text('Is Billable?'),
|
||||
subtitle: const Text('Task time will be invoiced'),
|
||||
value: _isBillable,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_isBillable = val;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (_isBillable) ...[
|
||||
const SizedBox(height: 16),
|
||||
PremiumTextField(
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
labelText: 'Hourly Rate',
|
||||
initialValue: _isEditing && _hourlyRate > 0 ? _hourlyRate.toString() : null,
|
||||
prefixIcon: const Icon(LucideIcons.indianRupee),
|
||||
validator: (val) => _isBillable && (val == null || val.isEmpty) ? 'Required for billable tasks' : null,
|
||||
onSaved: (val) => _hourlyRate = double.tryParse(val ?? '0') ?? 0,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: _isLoading ? null : _submit,
|
||||
child: _isLoading
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: Text(_isEditing ? 'Save Changes' : 'Create Task', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
_formKey.currentState!.save();
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final repo = ref.read(projectRepositoryProvider);
|
||||
|
||||
if (_isEditing) {
|
||||
await repo.updateTask(
|
||||
taskId: widget.task!.id,
|
||||
title: _title,
|
||||
description: _description.isNotEmpty ? _description : null,
|
||||
assigneeName: _assigneeName.isNotEmpty ? _assigneeName : null,
|
||||
isBillable: _isBillable,
|
||||
hourlyRate: _isBillable ? _hourlyRate : null,
|
||||
);
|
||||
} else {
|
||||
await repo.createTask(
|
||||
projectId: widget.projectId,
|
||||
title: _title,
|
||||
description: _description.isNotEmpty ? _description : null,
|
||||
assigneeName: _assigneeName.isNotEmpty ? _assigneeName : null,
|
||||
isBillable: _isBillable,
|
||||
hourlyRate: _isBillable ? _hourlyRate : null,
|
||||
);
|
||||
}
|
||||
|
||||
ref.invalidate(projectTasksProvider(widget.projectId));
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../domain/project_task.dart';
|
||||
import '../../providers/project_provider.dart';
|
||||
import 'task_details_sheet.dart';
|
||||
|
||||
class TaskCard extends ConsumerWidget {
|
||||
final ProjectTask task;
|
||||
final VoidCallback onGenerateInvoice;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
const TaskCard({
|
||||
super.key,
|
||||
required this.task,
|
||||
required this.onGenerateInvoice,
|
||||
this.onEdit,
|
||||
});
|
||||
|
||||
Color _statusColor(String status) {
|
||||
switch (status) {
|
||||
case 'TODO':
|
||||
return Colors.blueGrey;
|
||||
case 'IN_PROGRESS':
|
||||
return Colors.blue;
|
||||
case 'REVIEW':
|
||||
return Colors.orange;
|
||||
case 'DONE':
|
||||
return Colors.green;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
String _statusLabel(String status) {
|
||||
switch (status) {
|
||||
case 'TODO':
|
||||
return 'To Do';
|
||||
case 'IN_PROGRESS':
|
||||
return 'In Progress';
|
||||
case 'REVIEW':
|
||||
return 'Review';
|
||||
case 'DONE':
|
||||
return 'Done';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final statusColor = _statusColor(task.status);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => TaskDetailsSheet(task: task),
|
||||
);
|
||||
},
|
||||
onLongPress: () => _showTaskOptions(context, ref),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Status chip + overflow menu
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_statusLabel(task.status),
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildStatusDropdown(context, ref),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Task title
|
||||
Text(
|
||||
task.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
height: 1.3,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
// Description preview
|
||||
if (task.description != null && task.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
task.description!,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600, height: 1.4),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Bottom row: assignee + billable badge + invoice button
|
||||
Row(
|
||||
children: [
|
||||
if (task.assigneeName != null && task.assigneeName!.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.indigo.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.user, size: 12, color: Colors.indigo.shade400),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
task.assigneeName!,
|
||||
style: TextStyle(fontSize: 11, color: Colors.indigo.shade600, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
if (task.isBillable)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.indianRupee, size: 11, color: Colors.green.shade600),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'Billable',
|
||||
style: TextStyle(color: Colors.green.shade700, fontSize: 11, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (task.isBillable && task.status == 'DONE')
|
||||
SizedBox(
|
||||
height: 28,
|
||||
child: TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
backgroundColor: Colors.green.withValues(alpha: 0.1),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
icon: Icon(LucideIcons.receipt, size: 13, color: Colors.green.shade700),
|
||||
label: Text('Invoice', style: TextStyle(fontSize: 11, color: Colors.green.shade700, fontWeight: FontWeight.w600)),
|
||||
onPressed: onGenerateInvoice,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showTaskOptions(BuildContext context, WidgetRef ref) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) => Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 12),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(task.title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.eye),
|
||||
title: const Text('View Details'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => TaskDetailsSheet(task: task),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (onEdit != null)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.edit),
|
||||
title: const Text('Edit Task'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
onEdit!();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.trash2, color: Colors.red),
|
||||
title: const Text('Delete Task', style: TextStyle(color: Colors.red)),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (c) => AlertDialog(
|
||||
title: const Text('Delete Task'),
|
||||
content: Text('Are you sure you want to delete "${task.title}"?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(c, true),
|
||||
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm == true) {
|
||||
try {
|
||||
await ref.read(projectRepositoryProvider).deleteTask(task.id);
|
||||
ref.invalidate(projectTasksProvider(task.projectId));
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusDropdown(BuildContext context, WidgetRef ref) {
|
||||
return PopupMenuButton<String>(
|
||||
initialValue: task.status,
|
||||
tooltip: 'Change Status',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
child: Icon(LucideIcons.moreVertical, size: 18, color: Colors.grey.shade500),
|
||||
onSelected: (newStatus) async {
|
||||
if (newStatus != task.status) {
|
||||
try {
|
||||
await ref.read(projectRepositoryProvider).updateTask(
|
||||
taskId: task.id,
|
||||
status: newStatus,
|
||||
);
|
||||
ref.invalidate(projectTasksProvider(task.projectId));
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to update status: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
_buildStatusMenuItem('TODO', 'To Do', Colors.blueGrey),
|
||||
_buildStatusMenuItem('IN_PROGRESS', 'In Progress', Colors.blue),
|
||||
_buildStatusMenuItem('REVIEW', 'Review', Colors.orange),
|
||||
_buildStatusMenuItem('DONE', 'Done', Colors.green),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
PopupMenuItem<String> _buildStatusMenuItem(String value, String label, Color color) {
|
||||
return PopupMenuItem<String>(
|
||||
value: value,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(label),
|
||||
if (task.status == value) ...[
|
||||
const Spacer(),
|
||||
const Icon(LucideIcons.check, size: 16, color: Colors.green),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../domain/project_task.dart';
|
||||
import '../../providers/project_provider.dart';
|
||||
import '../../../transactions/presentation/widgets/attachment_gallery_screen.dart';
|
||||
|
||||
class TaskDetailsSheet extends ConsumerStatefulWidget {
|
||||
final ProjectTask task;
|
||||
|
||||
const TaskDetailsSheet({super.key, required this.task});
|
||||
|
||||
@override
|
||||
ConsumerState<TaskDetailsSheet> createState() => _TaskDetailsSheetState();
|
||||
}
|
||||
|
||||
class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
|
||||
final TextEditingController _commentController = TextEditingController();
|
||||
final List<String> _imagesBase64 = [];
|
||||
bool _isSubmitting = false;
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
if (_imagesBase64.length >= 3) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 3 images allowed')));
|
||||
return;
|
||||
}
|
||||
final ImagePicker picker = ImagePicker();
|
||||
final XFile? image = await picker.pickImage(source: ImageSource.gallery, imageQuality: 50);
|
||||
if (image != null) {
|
||||
final bytes = await image.readAsBytes();
|
||||
setState(() {
|
||||
_imagesBase64.add(base64Encode(bytes));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitComment() async {
|
||||
if (_commentController.text.trim().isEmpty && _imagesBase64.isEmpty) return;
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final repo = ref.read(projectRepositoryProvider);
|
||||
await repo.addComment(
|
||||
taskId: widget.task.id,
|
||||
content: _commentController.text.trim(),
|
||||
imagesBase64: _imagesBase64,
|
||||
);
|
||||
_commentController.clear();
|
||||
setState(() {
|
||||
_imagesBase64.clear();
|
||||
});
|
||||
ref.invalidate(projectTaskCommentsProvider(widget.task.id));
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error adding comment: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final commentsAsync = ref.watch(projectTaskCommentsProvider(widget.task.id));
|
||||
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.9,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Expanded(
|
||||
child: commentsAsync.when(
|
||||
data: (comments) {
|
||||
if (comments.isEmpty) return const Center(child: Text('No comments yet.'));
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: comments.length,
|
||||
itemBuilder: (context, index) {
|
||||
final c = comments[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
color: Colors.grey[100],
|
||||
elevation: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(c.content, style: const TextStyle(fontSize: 14)),
|
||||
if (c.images.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: c.images.asMap().entries.map((entry) {
|
||||
final imgIndex = entry.key;
|
||||
final img = entry.value;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final allImages = c.images.map((i) => MemoryImage(base64Decode(i)) as ImageProvider).toList();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AttachmentGalleryScreen(
|
||||
images: allImages,
|
||||
initialIndex: imgIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.memory(
|
||||
base64Decode(img),
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
)
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
),
|
||||
),
|
||||
_buildCommentInput(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: Colors.grey.shade300))),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.task.title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
if (widget.task.description != null && widget.task.description!.isNotEmpty)
|
||||
Text(widget.task.description!, style: const TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommentInput() {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_imagesBase64.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
children: _imagesBase64.asMap().entries.map((entry) {
|
||||
return Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.memory(
|
||||
base64Decode(entry.value),
|
||||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_imagesBase64.removeAt(entry.key);
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
color: Colors.black54,
|
||||
child: const Icon(LucideIcons.x, size: 16, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.image),
|
||||
onPressed: _pickImage,
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _commentController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Add a comment...',
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
_isSubmitting
|
||||
? const Padding(padding: EdgeInsets.all(12.0), child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))
|
||||
: IconButton(
|
||||
icon: const Icon(LucideIcons.send, color: Colors.blue),
|
||||
onPressed: _submitComment,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class ProjectModeNotifier extends Notifier<bool> {
|
||||
@override
|
||||
bool build() {
|
||||
_loadState();
|
||||
return false; // Default until loaded
|
||||
}
|
||||
|
||||
Future<void> _loadState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
state = prefs.getBool('is_project_mode') ?? false;
|
||||
}
|
||||
|
||||
Future<void> toggleMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
state = !state;
|
||||
await prefs.setBool('is_project_mode', state);
|
||||
}
|
||||
}
|
||||
|
||||
final projectModeProvider = NotifierProvider<ProjectModeNotifier, bool>(() {
|
||||
return ProjectModeNotifier();
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../auth/providers/auth_provider.dart';
|
||||
import '../data/project_repository.dart';
|
||||
import '../domain/project.dart';
|
||||
import '../domain/project_task.dart';
|
||||
import '../domain/project_task_comment.dart';
|
||||
|
||||
final projectRepositoryProvider = Provider<ProjectRepository>((ref) {
|
||||
return ProjectRepository(DioClient().dio);
|
||||
});
|
||||
|
||||
final projectsProvider = FutureProvider<List<Project>>((ref) async {
|
||||
final repo = ref.watch(projectRepositoryProvider);
|
||||
return repo.getProjects();
|
||||
});
|
||||
|
||||
final projectTasksProvider = FutureProvider.family<List<ProjectTask>, int>((ref, projectId) async {
|
||||
final repo = ref.watch(projectRepositoryProvider);
|
||||
return repo.getTasks(projectId);
|
||||
});
|
||||
|
||||
final projectTaskCommentsProvider = FutureProvider.family<List<ProjectTaskComment>, int>((ref, taskId) async {
|
||||
final repo = ref.watch(projectRepositoryProvider);
|
||||
return repo.getComments(taskId);
|
||||
});
|
||||
18
kifi-app/lib/features/projects/providers/user_provider.dart
Normal file
18
kifi-app/lib/features/projects/providers/user_provider.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../core/domain/user.dart';
|
||||
import 'dart:async';
|
||||
|
||||
final userSearchProvider = FutureProvider.family<List<User>, String>((ref, query) async {
|
||||
if (query.isEmpty) return [];
|
||||
|
||||
final dio = DioClient().dio;
|
||||
final response = await dio.get('/users/search', queryParameters: {'q': query});
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List data = response.data;
|
||||
return data.map((json) => User.fromJson(json)).toList();
|
||||
} else {
|
||||
throw Exception('Failed to search users');
|
||||
}
|
||||
});
|
||||
@@ -11,12 +11,16 @@ import '../domain/invoice.dart';
|
||||
import '../providers/customers_provider.dart';
|
||||
import '../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: '₹');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user