Files
tm_app/lib/views/board/pages/board_screen.dart
T
AmirReza Jamali 984f2e5139
continuous-integration/drone/push Build is passing
refactor: use GET tenders/recommend and improve tender title UX
Load the Recommended tab from GET /api/v1/tenders/recommend instead of the
AI POST flow with 503 fallback. Fix notification pagination offset, make
tender titles selectable across list and detail views, and ignore .cursor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 12:23:53 +03:30

420 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),
SelectableText(
card.title ?? '',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey70,
),
maxLines: 3,
),
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);
}