421 lines
13 KiB
Dart
421 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_svg/svg.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:tm_app/core/theme/colors.dart';
|
|
import 'package:tm_app/core/utils/date_utils.dart';
|
|
import 'package:tm_app/core/utils/result.dart';
|
|
import 'package:tm_app/core/utils/size_config.dart';
|
|
import 'package:tm_app/data/services/board_service.dart';
|
|
import 'package:tm_app/data/services/model/board_response/board_response.dart';
|
|
import 'package:tm_app/views/shared/desktop_navigation_widget.dart';
|
|
|
|
import '../../../core/constants/assets.dart';
|
|
import '../../../core/utils/app_toast.dart';
|
|
import '../../../view_models/board_view_model.dart';
|
|
import '../strings/board_strings.dart';
|
|
|
|
class BoardScreen extends StatefulWidget {
|
|
const BoardScreen({super.key});
|
|
|
|
@override
|
|
State<BoardScreen> createState() => _BoardScreenState();
|
|
}
|
|
|
|
class _BoardScreenState extends State<BoardScreen> {
|
|
late BoardViewModel _boardViewModel;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_boardViewModel = context.read<BoardViewModel>();
|
|
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
|
_boardViewModel.getBoard();
|
|
});
|
|
}
|
|
|
|
Future<void> _onCardDropped(BoardCard card, BoardColumn target) async {
|
|
final source = _boardViewModel.columnForCard(card.id ?? '');
|
|
if (source == null || source.id == target.id) return;
|
|
|
|
final result = await _boardViewModel.moveCard(
|
|
card: card,
|
|
targetColumn: target,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
if (result is Ok<MoveCardResponse>) {
|
|
AppToast.success(
|
|
context,
|
|
BoardStrings.cardMovedMessage(
|
|
source.name ?? '',
|
|
target.name ?? '',
|
|
),
|
|
);
|
|
} else if (result is Error<MoveCardResponse>) {
|
|
AppToast.error(context, BoardStrings.moveFailed);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
SizeConfig.init(context);
|
|
return Scaffold(
|
|
backgroundColor: AppColors.backgroundColor,
|
|
body: Column(
|
|
children: [
|
|
const DesktopNavigationWidget(currentIndex: 2),
|
|
Consumer<BoardViewModel>(
|
|
builder: (context, viewModel, child) {
|
|
if (viewModel.isLoading) {
|
|
return const Expanded(
|
|
child: Center(
|
|
child: CircularProgressIndicator(
|
|
color: AppColors.secondary50,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
if (viewModel.errorMessage != null) {
|
|
return Expanded(
|
|
child: Center(child: Text(viewModel.errorMessage!)),
|
|
);
|
|
}
|
|
if (!viewModel.hasData) {
|
|
return const Expanded(
|
|
child: Center(child: Text(BoardStrings.emptyListText)),
|
|
);
|
|
}
|
|
|
|
final columns = [...viewModel.columns]..sort(
|
|
(a, b) => (a.order ?? 0).compareTo(b.order ?? 0),
|
|
);
|
|
|
|
return Expanded(
|
|
child: SingleChildScrollView(
|
|
child: SizedBox(
|
|
width: 740,
|
|
height: 735,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 32),
|
|
Text(
|
|
viewModel.board?.name ?? BoardStrings.boardTitle,
|
|
style: TextStyle(
|
|
fontSize: 20.0.sp(),
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.titleColor,
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
color: AppColors.primary20,
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
for (int i = 0; i < columns.length; i++) ...[
|
|
if (i > 0) const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _BoardColumnView(
|
|
column: columns[i],
|
|
onCardDropped: _onCardDropped,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BoardColumnView extends StatelessWidget {
|
|
final BoardColumn column;
|
|
final Future<void> Function(BoardCard, BoardColumn) onCardDropped;
|
|
|
|
const _BoardColumnView({required this.column, required this.onCardDropped});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final accent = _parseHexColor(column.color) ?? AppColors.textBlue;
|
|
return DragTarget<BoardCard>(
|
|
onWillAcceptWithDetails: (details) => details.data.columnId != column.id,
|
|
onAcceptWithDetails: (details) => onCardDropped(details.data, column),
|
|
builder: (context, candidateData, rejectedData) {
|
|
final isHovering = candidateData.isNotEmpty;
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color:
|
|
isHovering
|
|
? accent.withValues(alpha: 0.15)
|
|
: AppColors.primary0,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: isHovering ? accent : AppColors.borderBlue,
|
|
width: isHovering ? 2 : 1,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
width: double.infinity,
|
|
height: 34,
|
|
margin: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: accent.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
_columnHeader(column),
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: accent,
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.all(12),
|
|
itemCount: column.cards.length,
|
|
itemBuilder: (context, index) {
|
|
return _TaskCard(card: column.cards[index], accent: accent);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
String _columnHeader(BoardColumn column) {
|
|
final name = column.name ?? '';
|
|
final limit = column.limit;
|
|
if (limit == null || limit <= 0) return name;
|
|
return '$name (${column.cards.length}/$limit)';
|
|
}
|
|
}
|
|
|
|
class _TaskCard extends StatelessWidget {
|
|
final BoardCard card;
|
|
final Color accent;
|
|
|
|
const _TaskCard({required this.card, required this.accent});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Draggable<BoardCard>(
|
|
data: card,
|
|
feedback: Material(
|
|
elevation: 8,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
width: 280,
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primary0,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: AppColors.borderBlue, width: 2),
|
|
),
|
|
child: _buildCardContent(context),
|
|
),
|
|
),
|
|
childWhenDragging: Opacity(opacity: 0.3, child: _buildCard(context)),
|
|
child: _buildCard(context),
|
|
);
|
|
}
|
|
|
|
Widget _buildCard(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primary0,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: AppColors.grey30),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.05),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: _buildCardContent(context),
|
|
);
|
|
}
|
|
|
|
Widget _buildCardContent(BuildContext context) {
|
|
final due = card.dueDate;
|
|
final labels = card.labels;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (due != null)
|
|
Container(
|
|
height: 24.0,
|
|
padding: EdgeInsets.symmetric(horizontal: 10.0.w()),
|
|
decoration: BoxDecoration(
|
|
color: accent.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'${BoardStrings.deadlineLabel} :',
|
|
style: TextStyle(
|
|
fontSize: 14.0,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.textBlue,
|
|
),
|
|
),
|
|
Text(
|
|
timeConvertor(due),
|
|
style: TextStyle(
|
|
fontSize: 14.0,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.grey80,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (due != null) const SizedBox(height: 12),
|
|
Text(
|
|
card.title ?? '',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.grey70,
|
|
),
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (card.priority != null) _PriorityChip(priority: card.priority!),
|
|
if (labels.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
children: [for (final label in labels) _LabelChip(label: label)],
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
SvgPicture.asset(AssetsManager.profile),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
card.tenderId ?? '',
|
|
style: TextStyle(fontSize: 12, color: AppColors.grey60),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PriorityChip extends StatelessWidget {
|
|
final String priority;
|
|
const _PriorityChip({required this.priority});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final (bg, fg, dot) = _styleFor(priority);
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
|
decoration: BoxDecoration(
|
|
color: bg,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(color: dot, shape: BoxShape.circle),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
priority,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: fg,
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
(Color bg, Color fg, Color dot) _styleFor(String priority) {
|
|
switch (priority.toLowerCase()) {
|
|
case 'urgent':
|
|
case 'high':
|
|
return (AppColors.red0, AppColors.red20, AppColors.error);
|
|
case 'low':
|
|
return (AppColors.primary20, AppColors.textBlue, AppColors.textBlue);
|
|
case 'medium':
|
|
default:
|
|
return (AppColors.orange10, AppColors.orange, AppColors.orange);
|
|
}
|
|
}
|
|
}
|
|
|
|
class _LabelChip extends StatelessWidget {
|
|
final String label;
|
|
const _LabelChip({required this.label});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primary20,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(fontSize: 11, color: AppColors.textBlue),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Color? _parseHexColor(String? hex) {
|
|
if (hex == null) return null;
|
|
var value = hex.trim();
|
|
if (value.startsWith('#')) value = value.substring(1);
|
|
if (value.length == 6) value = 'FF$value';
|
|
if (value.length != 8) return null;
|
|
final parsed = int.tryParse(value, radix: 16);
|
|
if (parsed == null) return null;
|
|
return Color(parsed);
|
|
}
|