mobile_team/lib/screens/project_detail_screen.dart
2026-04-16 15:06:41 +05:30

615 lines
22 KiB
Dart

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:provider/provider.dart';
import 'package:file_picker/file_picker.dart';
import 'package:open_filex/open_filex.dart';
import 'dart:io';
import '../models/project_model.dart';
import '../models/branch_model.dart';
import '../models/project_doc_model.dart';
import '../models/task_log_model.dart';
import '../services/project_service.dart';
import '../services/task_log_service.dart';
import 'project_form_screen.dart';
class ProjectDetailScreen extends StatefulWidget {
final ProjectModel project;
const ProjectDetailScreen({super.key, required this.project});
@override
State<ProjectDetailScreen> createState() => _ProjectDetailScreenState();
}
class _ProjectDetailScreenState extends State<ProjectDetailScreen> {
bool _isUploading = false;
bool _isEditMode = false;
late List<String> _currentFlavors;
@override
void initState() {
super.initState();
_currentFlavors = List.from(widget.project.flavors);
}
void _launchURL(String? url) async {
if (url == null || url.isEmpty) return;
final uri = Uri.parse(url);
try {
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
throw 'Could not launch $url';
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
void _addBranch() {
final controller = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("New Branch"),
content: TextField(
controller: controller,
decoration: const InputDecoration(hintText: "e.g. main, develop, feature/auth"),
autofocus: true,
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("CANCEL")),
TextButton(
onPressed: () {
if (controller.text.isNotEmpty) {
context.read<ProjectService>().addBranch(widget.project.id, controller.text.trim());
}
Navigator.pop(ctx);
},
child: const Text("ADD"),
),
],
),
);
}
void _addFlavor() {
final controller = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Add Project Flavor"),
content: TextField(
controller: controller,
decoration: const InputDecoration(hintText: "e.g. dev, prod, staging"),
autofocus: true,
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("CANCEL")),
TextButton(
onPressed: () async {
String f = controller.text.trim();
if (f.isNotEmpty && !_currentFlavors.contains(f)) {
setState(() => _currentFlavors.add(f));
final updatedProject = ProjectModel(
id: widget.project.id,
name: widget.project.name,
gitUrl: widget.project.gitUrl,
apiDocUrl: widget.project.apiDocUrl,
description: widget.project.description,
flavors: _currentFlavors,
createdAt: widget.project.createdAt,
);
await context.read<ProjectService>().updateProject(updatedProject);
}
if (mounted) Navigator.pop(ctx);
},
child: const Text("ADD"),
),
],
),
);
}
void _deleteFlavor(String flavor) async {
setState(() => _currentFlavors.remove(flavor));
final updatedProject = ProjectModel(
id: widget.project.id,
name: widget.project.name,
gitUrl: widget.project.gitUrl,
apiDocUrl: widget.project.apiDocUrl,
description: widget.project.description,
flavors: _currentFlavors,
createdAt: widget.project.createdAt,
);
await context.read<ProjectService>().updateProject(updatedProject);
}
void _addDocDialog() {
showModalBottomSheet(
context: context,
builder: (ctx) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.link, color: Colors.blue),
title: const Text("Add Link"),
onTap: () {
Navigator.pop(ctx);
_addLinkDoc();
},
),
ListTile(
leading: const Icon(Icons.picture_as_pdf, color: Colors.red),
title: const Text("Upload PDF"),
onTap: () {
Navigator.pop(ctx);
_uploadPDFDoc();
},
),
const SizedBox(height: 20),
],
),
);
}
void _addLinkDoc() {
final titleCtrl = TextEditingController();
final urlCtrl = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Add Link Document"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: titleCtrl, decoration: const InputDecoration(labelText: "Label")),
TextField(controller: urlCtrl, decoration: const InputDecoration(labelText: "URL")),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("CANCEL")),
TextButton(
onPressed: () {
if (titleCtrl.text.isNotEmpty && urlCtrl.text.isNotEmpty) {
context.read<ProjectService>().addDoc(
widget.project.id, titleCtrl.text.trim(), urlCtrl.text.trim(), 'link'
);
}
Navigator.pop(ctx);
},
child: const Text("SAVE"),
),
],
),
);
}
void _uploadPDFDoc() async {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
);
if (result != null) {
setState(() => _isUploading = true);
try {
File file = File(result.files.single.path!);
String label = result.files.single.name;
String url = await context.read<ProjectService>().uploadDoc(widget.project.id, file);
await context.read<ProjectService>().addDoc(widget.project.id, label, url, 'pdf');
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Upload failed: $e")));
} finally {
if (mounted) setState(() => _isUploading = false);
}
}
}
void _confirmDeleteProject() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Delete Project"),
content: Text("Are you sure? All data related to '${widget.project.name}' will be lost."),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("CANCEL")),
TextButton(
onPressed: () async {
await context.read<ProjectService>().deleteProject(widget.project.id);
if (mounted) {
Navigator.pop(ctx);
Navigator.pop(context);
}
},
child: const Text("DELETE PROJECT", style: TextStyle(color: Colors.red)),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8FAFC),
appBar: AppBar(
title: Text(widget.project.name),
actions: [
if (_isEditMode) ...[
IconButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => ProjectFormScreen(project: widget.project))
),
icon: const Icon(Icons.settings_outlined),
tooltip: "Project Settings",
),
IconButton(
onPressed: _confirmDeleteProject,
icon: const Icon(Icons.delete_forever, color: Colors.red),
tooltip: "Delete Project",
),
],
IconButton(
onPressed: () => setState(() => _isEditMode = !_isEditMode),
icon: Icon(_isEditMode ? Icons.check_circle_rounded : Icons.edit_rounded, color: _isEditMode ? Colors.green : null),
tooltip: _isEditMode ? "Finish Editing" : "Edit Mode",
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLatestActivity(),
const SizedBox(height: 32),
_buildSectionHeader("Quick Links"),
Row(
children: [
_linkTile("GIT REPO", Icons.code_rounded, widget.project.gitUrl),
if (widget.project.apiDocUrl != null) ...[
const SizedBox(width: 12),
_linkTile("API DOCS", Icons.api_rounded, widget.project.apiDocUrl!),
],
],
),
const SizedBox(height: 32),
_buildSectionHeader("Description"),
Text(
widget.project.description,
style: const TextStyle(height: 1.6, color: Color(0xFF475569)),
),
const SizedBox(height: 32),
_isEditMode
? _buildSectionHeaderWithAction("Branches", Icons.add_rounded, _addBranch)
: _buildSectionHeader("Branches"),
_buildBranchList(),
const SizedBox(height: 32),
_isEditMode
? _buildSectionHeaderWithAction("Project Flavors", Icons.add_rounded, _addFlavor)
: _buildSectionHeader("Project Flavors"),
_buildFlavorList(),
const SizedBox(height: 32),
_isEditMode
? _buildSectionHeaderWithAction("Documents", Icons.add_rounded, _addDocDialog)
: _buildSectionHeader("Documents"),
if (_isUploading) const Padding(
padding: EdgeInsets.only(bottom: 12),
child: LinearProgressIndicator(),
),
_buildDocList(),
],
),
),
);
}
Widget _buildLatestActivity() {
return FutureBuilder<TaskLogModel?>(
future: context.read<TaskLogService>().getLatestLogForProject(widget.project.id),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
height: 80,
alignment: Alignment.center,
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(24)),
child: const CircularProgressIndicator(strokeWidth: 2),
);
}
if (snapshot.hasError) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.red.shade50, borderRadius: BorderRadius.circular(16)),
child: Column(
children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(height: 8),
Text(
"Query Error: ${snapshot.error}. Check if a Firestore Index is needed.",
style: const TextStyle(color: Colors.red, fontSize: 11, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
],
),
);
}
final log = snapshot.data;
if (log == null) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.grey.shade100),
),
child: const Row(
children: [
Icon(Icons.info_outline_rounded, color: Colors.indigo),
SizedBox(width: 16),
Text(
"No activity tracked for this project yet.",
style: TextStyle(color: Color(0xFF64748B), fontSize: 13, fontWeight: FontWeight.w500),
),
],
),
);
}
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF1E293B), Color(0xFF0F172A)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: const Color(0xFF0F172A).withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"LATEST ACTIVITY",
style: TextStyle(color: Colors.white54, fontSize: 10, fontWeight: FontWeight.bold, letterSpacing: 1.5),
),
if (log.flavor != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF6366F1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
log.flavor!.toUpperCase(),
style: const TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 20),
Row(
children: [
CircleAvatar(
radius: 20,
backgroundColor: Colors.white.withOpacity(0.1),
child: const Icon(Icons.person_rounded, color: Colors.white70),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
log.userName,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 18),
),
Text(
"Worked on ${log.moduleName}",
style: const TextStyle(color: Colors.white60, fontSize: 12),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
DateFormat('dd MMM').format(log.date),
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14),
),
Text(
DateFormat('yyyy').format(log.date),
style: const TextStyle(color: Colors.white38, fontSize: 10),
),
],
),
],
),
],
),
);
},
);
}
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
title.toUpperCase(),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Color(0xFF6366F1),
letterSpacing: 1.2
),
),
);
}
Widget _buildSectionHeaderWithAction(String title, IconData icon, VoidCallback onTap) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildSectionHeader(title),
TextButton.icon(
onPressed: onTap,
icon: Icon(icon, size: 16),
label: const Text("ADD", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 11)),
style: TextButton.styleFrom(foregroundColor: const Color(0xFF6366F1)),
),
],
);
}
Widget _linkTile(String label, IconData icon, String url) {
return Expanded(
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: () => _launchURL(url),
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 20),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade100),
borderRadius: BorderRadius.circular(20),
),
child: Column(
children: [
Icon(icon, color: const Color(0xFF6366F1), size: 28),
const SizedBox(height: 12),
Text(
label,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 11, color: Color(0xFF1E293B), letterSpacing: 0.5),
),
],
),
),
),
),
);
}
Widget _buildBranchList() {
return StreamBuilder<List<BranchModel>>(
stream: context.read<ProjectService>().getBranches(widget.project.id),
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox();
final branches = snapshot.data!;
if (branches.isEmpty) return const Text("No active branches recorded.", style: TextStyle(color: Color(0xFF94A3B8), fontSize: 12, fontStyle: FontStyle.italic));
return Wrap(
spacing: 10,
runSpacing: 10,
children: branches.map((b) => Chip(
label: Text(b.name, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
backgroundColor: const Color(0xFFF1F5F9),
side: BorderSide.none,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onDeleted: _isEditMode ? () {
context.read<ProjectService>().deleteBranch(widget.project.id, b.id);
} : null,
deleteIconColor: const Color(0xFFF43F5E),
deleteIcon: const Icon(Icons.close_rounded, size: 14),
)).toList(),
);
},
);
}
Widget _buildFlavorList() {
if (_currentFlavors.isEmpty) {
return const Text("No flavors defined yet.", style: TextStyle(color: Color(0xFF94A3B8), fontSize: 12, fontStyle: FontStyle.italic));
}
return Wrap(
spacing: 10,
runSpacing: 10,
children: _currentFlavors.map((flavor) => Chip(
label: Text(flavor, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
backgroundColor: const Color(0xFFEEF2FF),
labelStyle: const TextStyle(color: Color(0xFF6366F1)),
side: BorderSide.none,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onDeleted: _isEditMode ? () => _deleteFlavor(flavor) : null,
deleteIconColor: const Color(0xFFF43F5E),
deleteIcon: const Icon(Icons.close_rounded, size: 14),
)).toList(),
);
}
Widget _buildDocList() {
return StreamBuilder<List<ProjectDocModel>>(
stream: context.read<ProjectService>().getDocs(widget.project.id),
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox();
final docs = snapshot.data!;
if (docs.isEmpty) return const Text("No documents or technical specs.", style: TextStyle(color: Color(0xFF94A3B8), fontSize: 12, fontStyle: FontStyle.italic));
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: docs.length,
itemBuilder: (context, index) {
final doc = docs[index];
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade100),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: (doc.type == 'pdf' ? const Color(0xFFF43F5E) : Colors.blue).withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
doc.type == 'pdf' ? Icons.picture_as_pdf_rounded : Icons.link_rounded,
color: doc.type == 'pdf' ? const Color(0xFFF43F5E) : Colors.blue,
size: 20,
),
),
title: Text(doc.label, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Color(0xFF1E293B))),
trailing: _isEditMode
? IconButton(
onPressed: () => context.read<ProjectService>().deleteDoc(widget.project.id, doc.id),
icon: const Icon(Icons.delete_outline_rounded, size: 18, color: Color(0xFFF43F5E)),
)
: const Icon(Icons.arrow_forward_ios_rounded, size: 14, color: Color(0xFF94A3B8)),
onTap: () {
if (!_isEditMode) {
if (doc.type == 'pdf') {
OpenFilex.open(doc.url);
} else {
_launchURL(doc.url);
}
}
},
),
);
},
);
},
);
}
}