godcrm/god_frame/lib/features/crm/ui/tickets_screen.dart
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

3202 lines
120 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../core/theme.dart';
import '../../chat/data/models.dart';
import '../../chat/providers/conversations_provider.dart';
import '../../../shared/utils/api_client.dart';
import '../../settings/providers/todo_view_provider.dart';
// Pre-calculated color constants to avoid .withOpacity() allocations
const _kTextMutedOp03 = Color(0x4D64748B); // GodTheme.textMuted (0xFF64748B) at 0.3
const _kTextMutedOp01 = Color(0x1A64748B); // GodTheme.textMuted at 0.1
const _kPrimaryOp07 = Color(0xB36366F1); // GodTheme.primary (0xFF6366F1) at 0.7
const _kPrimaryOp04 = Color(0x666366F1); // GodTheme.primary at 0.4
const _kPrimaryOp03 = Color(0x4D6366F1); // GodTheme.primary at 0.3
const _kPrimaryOp015 = Color(0x266366F1); // GodTheme.primary at 0.15
const _kPrimaryOp01 = Color(0x1A6366F1); // GodTheme.primary at 0.1
const _kSuccessOp09 = Color(0xE622C55E); // GodTheme.success (0xFF22C55E) at 0.9
const _kSuccessOp07 = Color(0xB322C55E); // GodTheme.success at 0.7
const _kSuccessOp04 = Color(0x6622C55E); // GodTheme.success at 0.4
const _kSuccessOp03 = Color(0x4D22C55E); // GodTheme.success at 0.3
const _kSuccessOp02 = Color(0x3322C55E); // GodTheme.success at 0.2
const _kSuccessOp005 = Color(0x0D22C55E); // GodTheme.success at 0.05
const _kSkyOp008 = Color(0x140EA5E9); // Color(0xFF0EA5E9) at 0.08
const _kSkyOp03 = Color(0x4D0EA5E9); // Color(0xFF0EA5E9) at 0.3
/// Icon for a status name (UI preference, not data-driven).
IconData _iconForStatus(String status) {
switch (status.toLowerCase().replaceAll('_', ' ')) {
case 'done':
case 'completed':
return Icons.check_circle_outline;
case 'in progress':
return Icons.play_circle_outline;
case 'review':
return Icons.rate_review_outlined;
case 'control':
return Icons.verified_outlined;
case 'rejected':
return Icons.cancel_outlined;
case 'assigned':
return Icons.person_add_outlined;
case 'on hold':
return Icons.pause_circle_outline;
default:
return Icons.radio_button_unchecked;
}
}
/// Sort options for tickets.
enum TicketSortField { created, deadlineFrom, deadlineTo, priority, status }
enum TicketSortOrder { asc, desc }
/// View modes for tickets.
enum TicketViewMode { list, calendar, todo }
/// Standalone Tickets screen with search, filters, deadlines, sort, and calendar.
class TicketsScreen extends ConsumerStatefulWidget {
const TicketsScreen({super.key});
@override
ConsumerState<TicketsScreen> createState() => _TicketsScreenState();
}
class _TicketsScreenState extends ConsumerState<TicketsScreen> {
final _searchController = TextEditingController();
String _searchQuery = '';
String? _statusFilter;
String? _priorityFilter;
bool _showSearch = false;
// Deadline filter
DateTime? _deadlineFromFilter;
DateTime? _deadlineToFilter;
// Sort
TicketSortField _sortField = TicketSortField.created;
TicketSortOrder _sortOrder = TicketSortOrder.desc;
// View mode
TicketViewMode _viewMode = TicketViewMode.list;
bool _userSwitchedMode = false; // prevent auto-switch overriding user choice
// Calendar
DateTime _calendarMonth = DateTime(DateTime.now().year, DateTime.now().month);
// Filter cache
List<Ticket>? _cachedFilteredTickets;
List<Ticket>? _lastRawTickets;
int _lastFilterHash = 0;
int _filterParamsHash() {
return Object.hash(_searchQuery, _statusFilter, _priorityFilter,
_deadlineFromFilter, _deadlineToFilter, _sortField, _sortOrder);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<Ticket> _applyFilters(List<Ticket> tickets) {
var filtered = tickets;
// Search filter
if (_searchQuery.isNotEmpty) {
final q = _searchQuery.toLowerCase();
filtered = filtered.where((t) {
return t.title.toLowerCase().contains(q) ||
(t.description?.toLowerCase().contains(q) ?? false) ||
(t.assignee?.toLowerCase().contains(q) ?? false) ||
(t.type?.toLowerCase().contains(q) ?? false) ||
'#${t.id}'.contains(q);
}).toList();
}
// Status filter
if (_statusFilter != null) {
filtered = filtered.where((t) =>
t.status.toLowerCase() == _statusFilter!.toLowerCase()).toList();
}
// Priority filter
if (_priorityFilter != null) {
filtered = filtered.where((t) =>
t.priority?.toLowerCase() == _priorityFilter!.toLowerCase()).toList();
}
// Deadline from filter
if (_deadlineFromFilter != null) {
filtered = filtered.where((t) {
if (t.deadlineTo != null) {
return !t.deadlineTo!.isBefore(_deadlineFromFilter!);
}
if (t.deadlineFrom != null) {
return !t.deadlineFrom!.isBefore(_deadlineFromFilter!);
}
return false;
}).toList();
}
// Deadline to filter
if (_deadlineToFilter != null) {
filtered = filtered.where((t) {
if (t.deadlineFrom != null) {
return !t.deadlineFrom!.isAfter(_deadlineToFilter!);
}
if (t.deadlineTo != null) {
return !t.deadlineTo!.isAfter(_deadlineToFilter!);
}
return false;
}).toList();
}
// Sort
filtered = List.from(filtered);
filtered.sort((a, b) {
int cmp = 0;
switch (_sortField) {
case TicketSortField.created:
cmp = a.createdAt.compareTo(b.createdAt);
break;
case TicketSortField.deadlineFrom:
final aDate = a.deadlineFrom;
final bDate = b.deadlineFrom;
if (aDate == null && bDate == null) {
cmp = 0;
} else if (aDate == null) {
cmp = 1;
} else if (bDate == null) {
cmp = -1;
} else {
cmp = aDate.compareTo(bDate);
}
break;
case TicketSortField.deadlineTo:
final aDate = a.deadlineTo;
final bDate = b.deadlineTo;
if (aDate == null && bDate == null) {
cmp = 0;
} else if (aDate == null) {
cmp = 1;
} else if (bDate == null) {
cmp = -1;
} else {
cmp = aDate.compareTo(bDate);
}
break;
case TicketSortField.priority:
cmp = _priorityRank(a.priority).compareTo(_priorityRank(b.priority));
break;
case TicketSortField.status:
cmp = _statusRank(a.status).compareTo(_statusRank(b.status));
break;
}
return _sortOrder == TicketSortOrder.asc ? cmp : -cmp;
});
return filtered;
}
/// Get dynamic statuses list (from provider or fallback).
List<TicketStatus> _getStatuses() {
return ref.read(ticketStatusesProvider).valueOrNull ?? List<TicketStatus>.from(defaultTicketStatuses);
}
/// Get dynamic priorities list (from provider or fallback).
List<TicketPriority> _getPriorities() {
return ref.read(ticketPrioritiesProvider).valueOrNull ?? List<TicketPriority>.from(defaultTicketPriorities);
}
/// Get status color dynamically.
Color _statusColor(String status) {
return statusColorFromList(status, _getStatuses());
}
/// Get priority color dynamically.
Color _priorityColor(String priority) {
return priorityColorFromList(priority, _getPriorities());
}
int _priorityRank(String? p) {
if (p == null) return 999;
final priorities = _getPriorities();
final lower = p.toLowerCase();
// Higher level = higher priority = lower rank (sort first)
for (int i = 0; i < priorities.length; i++) {
if (priorities[i].name.toLowerCase() == lower) {
return priorities.length - priorities[i].level;
}
}
return 999;
}
int _statusRank(String s) {
final statuses = _getStatuses();
final lower = s.toLowerCase().replaceAll('_', ' ');
for (int i = 0; i < statuses.length; i++) {
if (statuses[i].name.toLowerCase() == lower) return statuses[i].order;
}
// Aliases
if (lower == 'completed') {
for (int i = 0; i < statuses.length; i++) {
if (statuses[i].name.toLowerCase() == 'done') return statuses[i].order;
}
}
return 999;
}
bool get _hasActiveFilters =>
_statusFilter != null ||
_priorityFilter != null ||
_deadlineFromFilter != null ||
_deadlineToFilter != null;
/// Show space picker bottom sheet.
void _showSpacePicker(BuildContext context) {
final spacesAsync = ref.read(spacesProvider);
final currentSpaceId = ref.read(selectedSpaceProvider);
showModalBottomSheet(
context: context,
backgroundColor: GodTheme.card,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
return spacesAsync.when(
data: (spaces) => Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
Container(
width: 40, height: 4,
decoration: BoxDecoration(
color: GodTheme.border,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 12),
const Text('Select Space',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: GodTheme.textPrimary)),
const SizedBox(height: 8),
ListTile(
leading: Icon(Icons.grid_view_rounded,
color: currentSpaceId == null ? GodTheme.primary : GodTheme.textMuted),
title: Text('All Spaces',
style: TextStyle(
color: currentSpaceId == null ? GodTheme.primary : GodTheme.textPrimary,
fontWeight: currentSpaceId == null ? FontWeight.w600 : FontWeight.w400,
)),
trailing: currentSpaceId == null
? const Icon(Icons.check_circle, color: GodTheme.primary, size: 20)
: null,
onTap: () {
ref.read(selectedSpaceProvider.notifier).state = null;
Navigator.pop(ctx);
},
),
...spaces.map((space) {
final isSelected = currentSpaceId == space.id;
return ListTile(
leading: Icon(Icons.workspaces_outlined,
color: isSelected ? GodTheme.primary : GodTheme.textMuted),
title: Text(space.name,
style: TextStyle(
color: isSelected ? GodTheme.primary : GodTheme.textPrimary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
)),
trailing: isSelected
? const Icon(Icons.check_circle, color: GodTheme.primary, size: 20)
: null,
onTap: () {
ref.read(selectedSpaceProvider.notifier).state = space.id;
Navigator.pop(ctx);
},
);
}),
const SizedBox(height: 16),
],
),
loading: () => const SizedBox(height: 100, child: Center(child: CircularProgressIndicator())),
error: (e, _) => SizedBox(height: 100, child: Center(child: Text('Error: $e'))),
);
},
);
}
/// Get the current space name for display.
String _currentSpaceName() {
final spaceId = ref.watch(selectedSpaceProvider);
if (spaceId == null) return 'All Spaces';
final spaces = ref.watch(spacesProvider).valueOrNull ?? [];
for (final s in spaces) {
if (s.id == spaceId) return s.name;
}
return 'Space #$spaceId';
}
@override
Widget build(BuildContext context) {
final ticketsAsync = ref.watch(ticketsProvider);
final selectedSpaceId = ref.watch(selectedSpaceProvider);
final todoSettings = ref.watch(todoViewProvider);
// Auto-switch to todo mode on first load if enabled in settings
// (only once — don't override manual user selection)
if (todoSettings.enabled && _viewMode == TicketViewMode.list && !_userSwitchedMode) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _viewMode == TicketViewMode.list && !_userSwitchedMode) {
setState(() => _viewMode = TicketViewMode.todo);
}
});
}
return Scaffold(
appBar: AppBar(
title: _showSearch
? TextField(
controller: _searchController,
autofocus: true,
style: const TextStyle(color: GodTheme.textPrimary, fontSize: 16),
decoration: const InputDecoration(
hintText: 'Search tickets...',
hintStyle: TextStyle(color: GodTheme.textMuted),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true,
),
onChanged: (v) => setState(() => _searchQuery = v),
)
: GestureDetector(
onTap: () => _showSpacePicker(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (selectedSpaceId != null) ...[
Icon(Icons.workspaces_outlined,
size: 18, color: GodTheme.primary),
const SizedBox(width: 6),
],
Flexible(
child: Text(
selectedSpaceId != null ? _currentSpaceName() : 'Tickets',
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_drop_down, size: 20),
],
),
),
actions: [
// Search toggle
IconButton(
icon: Icon(_showSearch ? Icons.close : Icons.search),
onPressed: () {
setState(() {
_showSearch = !_showSearch;
if (!_showSearch) {
_searchController.clear();
_searchQuery = '';
}
});
},
),
// Sort button
IconButton(
icon: const Icon(Icons.sort, size: 22),
tooltip: 'Sort',
onPressed: () => _showSortSheet(context),
),
// Filter button
IconButton(
icon: Badge(
isLabelVisible: _hasActiveFilters,
smallSize: 8,
child: const Icon(Icons.filter_list),
),
onPressed: () => _showFilterSheet(context),
),
// Refresh
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.read(ticketsProvider.notifier).refresh(),
),
],
),
body: Column(
children: [
// View mode segmented switcher
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
color: GodTheme.surface,
child: Row(
children: [
_ViewModeButton(
icon: Icons.list,
label: 'Список',
isSelected: _viewMode == TicketViewMode.list,
onTap: () => setState(() { _viewMode = TicketViewMode.list; _userSwitchedMode = true; }),
),
const SizedBox(width: 6),
_ViewModeButton(
icon: Icons.checklist_rounded,
label: 'Задачи',
isSelected: _viewMode == TicketViewMode.todo,
onTap: () => setState(() { _viewMode = TicketViewMode.todo; _userSwitchedMode = true; }),
),
const SizedBox(width: 6),
_ViewModeButton(
icon: Icons.calendar_month_outlined,
label: 'Календарь',
isSelected: _viewMode == TicketViewMode.calendar,
onTap: () => setState(() { _viewMode = TicketViewMode.calendar; _userSwitchedMode = true; }),
),
],
),
),
// Active filters chips
if (_hasActiveFilters)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
color: GodTheme.surfaceLight,
child: Wrap(
spacing: 6,
runSpacing: 4,
children: [
if (_statusFilter != null)
_FilterChip(
label: _statusFilter!.replaceAll('_', ' '),
color: _statusColor(_statusFilter!),
onRemove: () => setState(() => _statusFilter = null),
),
if (_priorityFilter != null)
_FilterChip(
label: _priorityFilter!,
color: _priorityColor(_priorityFilter!),
onRemove: () => setState(() => _priorityFilter = null),
),
if (_deadlineFromFilter != null)
_FilterChip(
label: 'From: ${DateFormat('dd.MM').format(_deadlineFromFilter!)}',
color: const Color(0xFF0EA5E9),
onRemove: () => setState(() => _deadlineFromFilter = null),
),
if (_deadlineToFilter != null)
_FilterChip(
label: 'To: ${DateFormat('dd.MM').format(_deadlineToFilter!)}',
color: const Color(0xFF0EA5E9),
onRemove: () => setState(() => _deadlineToFilter = null),
),
GestureDetector(
onTap: () => setState(() {
_statusFilter = null;
_priorityFilter = null;
_deadlineFromFilter = null;
_deadlineToFilter = null;
}),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Text('Clear all',
style: TextStyle(color: GodTheme.primary, fontSize: 12)),
),
),
],
),
),
// Tickets content (list or calendar)
Expanded(
child: ticketsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: GodTheme.error),
const SizedBox(height: 12),
Text(err.toString(),
style: const TextStyle(color: GodTheme.textSecondary)),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => ref.read(ticketsProvider.notifier).refresh(),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
data: (tickets) {
final filterHash = _filterParamsHash();
if (!identical(tickets, _lastRawTickets) || filterHash != _lastFilterHash) {
_cachedFilteredTickets = _applyFilters(tickets);
_lastRawTickets = tickets;
_lastFilterHash = filterHash;
}
final filtered = _cachedFilteredTickets!;
if (tickets.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.assignment_outlined, size: 64,
color: _kTextMutedOp03),
const SizedBox(height: 16),
const Text('No tickets found',
style: TextStyle(color: GodTheme.textSecondary)),
const SizedBox(height: 8),
const Text('Tickets from your CRM will appear here',
style: TextStyle(color: GodTheme.textMuted, fontSize: 13)),
],
),
);
}
if (_viewMode == TicketViewMode.todo) {
return _TodoView(
tickets: filtered,
statuses: _getStatuses(),
priorities: _getPriorities(),
todoSettings: todoSettings,
onTicketTap: (t) => _showTicketDetail(context, t),
onTicketChat: (t) => _openTicketChat(context, t),
);
}
if (_viewMode == TicketViewMode.calendar) {
return _CalendarView(
tickets: filtered,
allTickets: tickets,
currentMonth: _calendarMonth,
onMonthChanged: (m) => setState(() => _calendarMonth = m),
onTicketTap: (t) => _showTicketDetail(context, t),
statuses: _getStatuses(),
priorities: _getPriorities(),
);
}
if (filtered.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64,
color: _kTextMutedOp03),
const SizedBox(height: 16),
const Text('No matching tickets',
style: TextStyle(color: GodTheme.textSecondary)),
const SizedBox(height: 8),
TextButton(
onPressed: () => setState(() {
_searchController.clear();
_searchQuery = '';
_statusFilter = null;
_priorityFilter = null;
_deadlineFromFilter = null;
_deadlineToFilter = null;
}),
child: const Text('Clear filters'),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () => ref.read(ticketsProvider.notifier).refresh(),
child: ListView.separated(
padding: const EdgeInsets.only(top: 4, bottom: 80),
itemCount: filtered.length,
separatorBuilder: (_, __) =>
const Divider(height: 1, indent: 16, endIndent: 16),
itemBuilder: (context, index) {
final ticket = filtered[index];
return _TicketTile(key: ValueKey(ticket.id), ticket: ticket);
},
),
);
},
),
),
],
),
// FAB to create ticket
floatingActionButton: FloatingActionButton(
onPressed: () => _showCreateTicketDialog(context),
backgroundColor: GodTheme.primary,
child: const Icon(Icons.add, color: Colors.white),
),
);
}
/// Show sort bottom sheet.
void _showSortSheet(BuildContext context) {
showModalBottomSheet(
context: context,
backgroundColor: GodTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheetState) => Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40, height: 4,
decoration: BoxDecoration(
color: _kTextMutedOp03,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
const Text('Sort Tickets',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18,
color: GodTheme.textPrimary)),
const SizedBox(height: 16),
// Sort field options
_buildSortOption(ctx, setSheetState, 'Created date', TicketSortField.created, Icons.access_time),
_buildSortOption(ctx, setSheetState, 'Deadline start', TicketSortField.deadlineFrom, Icons.event),
_buildSortOption(ctx, setSheetState, 'Deadline end', TicketSortField.deadlineTo, Icons.event_available),
_buildSortOption(ctx, setSheetState, 'Priority', TicketSortField.priority, Icons.flag),
_buildSortOption(ctx, setSheetState, 'Status', TicketSortField.status, Icons.circle),
const SizedBox(height: 8),
],
),
),
),
);
}
Widget _buildSortOption(BuildContext ctx, StateSetter setSheetState,
String label, TicketSortField field, IconData icon) {
final isSelected = _sortField == field;
return ListTile(
leading: Icon(icon, color: isSelected ? GodTheme.primary : GodTheme.textMuted, size: 20),
title: Text(label,
style: TextStyle(
color: isSelected ? GodTheme.primary : GodTheme.textPrimary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
fontSize: 14,
)),
trailing: isSelected
? Icon(
_sortOrder == TicketSortOrder.asc
? Icons.arrow_upward
: Icons.arrow_downward,
color: GodTheme.primary,
size: 18,
)
: null,
dense: true,
onTap: () {
setState(() {
if (_sortField == field) {
_sortOrder = _sortOrder == TicketSortOrder.asc
? TicketSortOrder.desc
: TicketSortOrder.asc;
} else {
_sortField = field;
_sortOrder = TicketSortOrder.desc;
}
});
setSheetState(() {});
Navigator.pop(ctx);
},
);
}
/// Show filter bottom sheet.
void _showFilterSheet(BuildContext context) {
showModalBottomSheet(
context: context,
backgroundColor: GodTheme.surface,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => DraggableScrollableSheet(
initialChildSize: 0.55,
minChildSize: 0.3,
maxChildSize: 0.85,
expand: false,
builder: (ctx, scrollController) => SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40, height: 4,
decoration: BoxDecoration(
color: _kTextMutedOp03,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
const Text('Filter Tickets',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18,
color: GodTheme.textPrimary)),
const SizedBox(height: 16),
// Status filter (dynamic from CRM)
const Text('Status', style: TextStyle(color: GodTheme.textSecondary,
fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
_buildFilterOption(null, 'All', GodTheme.textMuted,
isSelected: _statusFilter == null,
onTap: () { setState(() => _statusFilter = null); Navigator.pop(ctx); }),
..._getStatuses().map((s) => _buildFilterOption(
s.name, s.name.replaceAll('_', ' '), s.color,
isSelected: _statusFilter == s.name,
onTap: () { setState(() => _statusFilter = s.name); Navigator.pop(ctx); },
)),
],
),
const SizedBox(height: 16),
// Priority filter (dynamic from CRM)
const Text('Priority', style: TextStyle(color: GodTheme.textSecondary,
fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
_buildFilterOption(null, 'All', GodTheme.textMuted,
isSelected: _priorityFilter == null,
onTap: () { setState(() => _priorityFilter = null); Navigator.pop(ctx); }),
..._getPriorities().reversed.map((p) => _buildFilterOption(
p.name, p.name, p.color,
isSelected: _priorityFilter == p.name,
onTap: () { setState(() => _priorityFilter = p.name); Navigator.pop(ctx); },
)),
],
),
const SizedBox(height: 16),
// Deadline range filter
const Text('Deadline Range', style: TextStyle(color: GodTheme.textSecondary,
fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _DatePickerButton(
label: 'From',
date: _deadlineFromFilter,
onPicked: (d) {
setState(() => _deadlineFromFilter = d);
Navigator.pop(ctx);
},
onClear: () {
setState(() => _deadlineFromFilter = null);
Navigator.pop(ctx);
},
),
),
const SizedBox(width: 12),
Expanded(
child: _DatePickerButton(
label: 'To',
date: _deadlineToFilter,
onPicked: (d) {
setState(() => _deadlineToFilter = d);
Navigator.pop(ctx);
},
onClear: () {
setState(() => _deadlineToFilter = null);
Navigator.pop(ctx);
},
),
),
],
),
const SizedBox(height: 24),
],
),
),
),
);
}
Widget _buildFilterOption(String? value, String label, Color color,
{required bool isSelected, required VoidCallback onTap}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? color.withOpacity(0.2) : GodTheme.surfaceLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? color : GodTheme.border,
width: isSelected ? 1.5 : 1,
),
),
child: Text(
label.toUpperCase(),
style: TextStyle(
color: isSelected ? color : GodTheme.textSecondary,
fontSize: 11,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
),
),
),
);
}
/// Show create ticket dialog with space selector.
void _showEmojiPicker(BuildContext context, ValueChanged<String> onSelected) {
const emojis = [
'🎯', '🐛', '🔥', '🚀', '💡', '📝', '🔧', '', '🎨', '📦',
'🔒', '🌟', '📊', '🎪', '🏗️', '💎', '🎵', '📱', '🖥️', '🌐',
'', '', '⚠️', '🔴', '🟢', '🔵', '🟡', '🟣', '', '',
'🐕', '🦊', '🐱', '🐸', '🦁', '🐙', '🦄', '🐬', '🎲', '🎮',
];
showModalBottomSheet(
context: context,
backgroundColor: GodTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Pick Emoji', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16, color: GodTheme.textPrimary)),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: emojis.map((e) => GestureDetector(
onTap: () {
Navigator.pop(ctx);
onSelected(e);
},
child: Container(
width: 40, height: 40,
decoration: BoxDecoration(
color: GodTheme.surfaceLight,
borderRadius: BorderRadius.circular(8),
),
child: Center(child: Text(e, style: const TextStyle(fontSize: 22))),
),
)).toList(),
),
const SizedBox(height: 12),
],
),
),
);
}
void _showCreateTicketDialog(BuildContext context) {
final titleCtrl = TextEditingController();
final descCtrl = TextEditingController();
String selectedStatus = 'backlog';
String selectedPriority = 'medium';
DateTime? deadlineFrom;
DateTime? deadlineTo;
String? selectedEmoji;
String? selectedColor;
// Auto-select current space if one is active
final currentSpaceId = ref.read(selectedSpaceProvider);
int? selectedSpaceId = currentSpaceId;
showModalBottomSheet(
context: context,
backgroundColor: GodTheme.surface,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => Padding(
padding: EdgeInsets.only(
left: 20, right: 20, top: 16,
bottom: MediaQuery.of(ctx).viewInsets.bottom + 20,
),
child: StatefulBuilder(
builder: (ctx, setSheetState) {
final spacesAsync = ref.watch(spacesProvider);
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40, height: 4,
decoration: BoxDecoration(
color: _kTextMutedOp03,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
const Text('Create Ticket',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18,
color: GodTheme.textPrimary)),
const SizedBox(height: 16),
// Space selector
const Text('Space', style: TextStyle(
color: GodTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: GodTheme.surfaceLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: GodTheme.border),
),
child: spacesAsync.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
error: (_, __) => const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('Failed to load spaces',
style: TextStyle(color: GodTheme.error, fontSize: 13)),
),
data: (spaces) => DropdownButton<int?>(
value: selectedSpaceId,
isExpanded: true,
underline: const SizedBox.shrink(),
dropdownColor: GodTheme.surface,
hint: const Text('Select space...',
style: TextStyle(color: GodTheme.textMuted, fontSize: 13)),
style: const TextStyle(color: GodTheme.textPrimary, fontSize: 13),
items: spaces.map((s) => DropdownMenuItem<int?>(
value: s.id,
child: Row(
children: [
Icon(Icons.workspaces_outlined, size: 16,
color: _kPrimaryOp07),
const SizedBox(width: 8),
Flexible(child: Text(s.name, overflow: TextOverflow.ellipsis)),
],
),
)).toList(),
onChanged: (v) {
setSheetState(() => selectedSpaceId = v);
},
),
),
),
if (currentSpaceId != null && selectedSpaceId == currentSpaceId)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
Icon(Icons.auto_fix_high, size: 12, color: _kSuccessOp07),
const SizedBox(width: 4),
Text('Auto-selected from current space',
style: TextStyle(color: _kSuccessOp07, fontSize: 11)),
],
),
),
const SizedBox(height: 12),
// Title
TextField(
controller: titleCtrl,
style: const TextStyle(color: GodTheme.textPrimary),
decoration: const InputDecoration(
labelText: 'Title *',
hintText: 'Ticket title...',
),
autofocus: true,
),
const SizedBox(height: 12),
// Description
TextField(
controller: descCtrl,
style: const TextStyle(color: GodTheme.textPrimary),
decoration: const InputDecoration(
labelText: 'Description',
hintText: 'Optional description...',
),
maxLines: 3,
minLines: 1,
),
const SizedBox(height: 12),
// Status + Priority row (dynamic from CRM)
Builder(builder: (_) {
final statuses = _getStatuses();
final priorities = _getPriorities();
// Ensure selected values exist in dynamic lists
if (!statuses.any((s) => s.name == selectedStatus)) {
selectedStatus = statuses.isNotEmpty ? statuses.first.name : 'backlog';
}
if (!priorities.any((p) => p.name == selectedPriority)) {
selectedPriority = priorities.isNotEmpty
? priorities[priorities.length > 1 ? 1 : 0].name
: 'medium';
}
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Status', style: TextStyle(
color: GodTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: GodTheme.surfaceLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: GodTheme.border),
),
child: DropdownButton<String>(
value: selectedStatus,
isExpanded: true,
underline: const SizedBox.shrink(),
dropdownColor: GodTheme.surface,
style: const TextStyle(color: GodTheme.textPrimary, fontSize: 13),
items: statuses.map((s) => DropdownMenuItem(
value: s.name,
child: Row(
children: [
Container(
width: 8, height: 8,
decoration: BoxDecoration(
color: s.color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Flexible(child: Text(s.name.replaceAll('_', ' '),
overflow: TextOverflow.ellipsis)),
],
),
)).toList(),
onChanged: (v) {
if (v != null) setSheetState(() => selectedStatus = v);
},
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Priority', style: TextStyle(
color: GodTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: GodTheme.surfaceLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: GodTheme.border),
),
child: DropdownButton<String>(
value: selectedPriority,
isExpanded: true,
underline: const SizedBox.shrink(),
dropdownColor: GodTheme.surface,
style: const TextStyle(color: GodTheme.textPrimary, fontSize: 13),
items: priorities.map((p) => DropdownMenuItem(
value: p.name,
child: Row(
children: [
Container(
width: 8, height: 8,
decoration: BoxDecoration(
color: p.color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(p.name),
],
),
)).toList(),
onChanged: (v) {
if (v != null) setSheetState(() => selectedPriority = v);
},
),
),
],
),
),
],
);
}),
const SizedBox(height: 12),
// Emoji + Color row
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Emoji', style: TextStyle(
color: GodTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 4),
GestureDetector(
onTap: () {
_showEmojiPicker(ctx, (emoji) {
setSheetState(() => selectedEmoji = emoji);
});
},
child: Container(
height: 44,
decoration: BoxDecoration(
color: GodTheme.surfaceLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: GodTheme.border),
),
child: Center(
child: selectedEmoji != null
? Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(selectedEmoji!, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 4),
GestureDetector(
onTap: () => setSheetState(() => selectedEmoji = null),
child: const Icon(Icons.close, size: 14, color: GodTheme.textMuted),
),
],
)
: const Text('Pick...', style: TextStyle(color: GodTheme.textMuted, fontSize: 13)),
),
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Color', style: TextStyle(
color: GodTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 4),
Container(
height: 44,
decoration: BoxDecoration(
color: GodTheme.surfaceLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: GodTheme.border),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (final c in const [
'#EF4444', '#F59E0B', '#10B981', '#3B82F6',
'#8B5CF6', '#EC4899', '#6B7280',
])
GestureDetector(
onTap: () => setSheetState(() =>
selectedColor = selectedColor == c ? null : c),
child: Container(
width: 22, height: 22,
decoration: BoxDecoration(
color: Color(int.parse('FF${c.substring(1)}', radix: 16)),
shape: BoxShape.circle,
border: selectedColor == c
? Border.all(color: Colors.white, width: 2)
: null,
),
),
),
],
),
),
],
),
),
],
),
const SizedBox(height: 12),
// Deadline from / to
const Text('Deadline', style: TextStyle(
color: GodTheme.textSecondary, fontSize: 12)),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: _DatePickerButton(
label: 'Start',
date: deadlineFrom,
compact: true,
onPicked: (d) => setSheetState(() => deadlineFrom = d),
onClear: () => setSheetState(() => deadlineFrom = null),
),
),
const SizedBox(width: 12),
Expanded(
child: _DatePickerButton(
label: 'Due',
date: deadlineTo,
compact: true,
onPicked: (d) => setSheetState(() => deadlineTo = d),
onClear: () => setSheetState(() => deadlineTo = null),
),
),
],
),
const SizedBox(height: 20),
// Create button
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
if (titleCtrl.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Title is required')),
);
return;
}
Navigator.pop(ctx);
_createTicket(
context,
title: titleCtrl.text.trim(),
description: descCtrl.text.trim().isEmpty
? null : descCtrl.text.trim(),
status: selectedStatus,
priority: selectedPriority,
spaceId: selectedSpaceId,
deadlineFrom: deadlineFrom,
deadlineTo: deadlineTo,
emoji: selectedEmoji,
color: selectedColor,
);
},
icon: const Icon(Icons.add),
label: const Text('Create Ticket'),
),
),
const SizedBox(height: 8),
],
),
);
},
),
),
);
}
/// Create ticket via API.
Future<void> _createTicket(
BuildContext context, {
required String title,
String? description,
required String status,
required String priority,
int? spaceId,
DateTime? deadlineFrom,
DateTime? deadlineTo,
String? emoji,
String? color,
}) async {
try {
final apiClient = ref.read(apiClientProvider);
final effectiveSpaceId = spaceId ?? ref.read(selectedSpaceProvider);
// Find the right table for the selected space
final repo = ref.read(chatRepositoryProvider);
final tablesResult = await repo.getTables(spaceId: effectiveSpaceId);
int tableId = 1708; // default
if (tablesResult.isSuccess && tablesResult.data != null) {
for (final t in tablesResult.data!) {
final name = t.name.toLowerCase();
if (name.contains('ticket') || name == 'my tasks data' || name.contains('task')) {
tableId = t.id;
break;
}
}
}
final data = <String, dynamic>{
'title': title,
'status': status,
'priority': priority,
};
if (description != null) data['description'] = description;
if (emoji != null) data['emoji'] = emoji;
if (color != null) data['color'] = color;
if (deadlineFrom != null) {
data['deadline_from'] = deadlineFrom.toIso8601String().split('T')[0];
}
if (deadlineTo != null) {
data['deadline_to'] = deadlineTo.toIso8601String().split('T')[0];
}
await apiClient.post(
'/tables/$tableId/rows',
data: {'data': data},
);
// Refresh tickets list
ref.read(ticketsProvider.notifier).refresh();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ticket "$title" created'),
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to create ticket: $e'),
backgroundColor: GodTheme.error,
),
);
}
}
}
/// Open chat for a ticket.
Future<void> _openTicketChat(BuildContext context, Ticket ticket) async {
try {
final conv = await ref.read(conversationsProvider.notifier).create(
title: '#${ticket.id}: ${ticket.title}',
);
if (conv != null && context.mounted) {
context.go('/chat/${conv.id}');
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to create chat: $e'),
backgroundColor: GodTheme.error),
);
}
}
}
/// Show ticket detail bottom sheet.
void _showTicketDetail(BuildContext context, Ticket ticket) {
showModalBottomSheet(
context: context,
backgroundColor: GodTheme.surface,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => DraggableScrollableSheet(
initialChildSize: 0.45,
minChildSize: 0.2,
maxChildSize: 0.8,
expand: false,
builder: (_, scrollController) => SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.all(20),
child: _TicketDetailContent(ticket: ticket),
),
),
);
}
}
/// Active filter chip with remove button.
class _FilterChip extends StatelessWidget {
final String label;
final Color color;
final VoidCallback onRemove;
const _FilterChip({
required this.label,
required this.color,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(left: 8, right: 4, top: 3, bottom: 3),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withOpacity(0.3), width: 0.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label.toUpperCase(),
style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w600)),
const SizedBox(width: 2),
GestureDetector(
onTap: onRemove,
child: Icon(Icons.close, size: 14, color: color),
),
],
),
);
}
}
/// View mode button for segmented switcher.
class _ViewModeButton extends StatelessWidget {
final IconData icon;
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ViewModeButton({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 7),
decoration: BoxDecoration(
color: isSelected
? _kPrimaryOp015
: GodTheme.surfaceLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected
? _kPrimaryOp04
: GodTheme.border,
width: isSelected ? 1.5 : 0.5,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 16,
color: isSelected ? GodTheme.primary : GodTheme.textMuted),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected ? GodTheme.primary : GodTheme.textSecondary,
),
),
],
),
),
),
);
}
}
/// Date picker button for filter/create forms.
class _DatePickerButton extends StatelessWidget {
final String label;
final DateTime? date;
final ValueChanged<DateTime> onPicked;
final VoidCallback onClear;
final bool compact;
const _DatePickerButton({
required this.label,
this.date,
required this.onPicked,
required this.onClear,
this.compact = false,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: date ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.dark(
primary: GodTheme.primary,
surface: GodTheme.surface,
onSurface: GodTheme.textPrimary,
),
),
child: child!,
);
},
);
if (picked != null) onPicked(picked);
},
onLongPress: date != null ? onClear : null,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 8 : 12,
vertical: compact ? 8 : 10,
),
decoration: BoxDecoration(
color: date != null
? _kSkyOp008
: GodTheme.surfaceLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: date != null
? _kSkyOp03
: GodTheme.border,
),
),
child: Row(
children: [
Icon(Icons.calendar_today, size: compact ? 14 : 16,
color: date != null ? const Color(0xFF0EA5E9) : GodTheme.textMuted),
const SizedBox(width: 6),
Expanded(
child: Text(
date != null
? DateFormat('dd.MM.yyyy').format(date!)
: label,
style: TextStyle(
color: date != null ? const Color(0xFF0EA5E9) : GodTheme.textMuted,
fontSize: compact ? 12 : 13,
fontWeight: date != null ? FontWeight.w600 : FontWeight.w400,
),
),
),
if (date != null)
GestureDetector(
onTap: onClear,
child: const Icon(Icons.close, size: 14, color: Color(0xFF0EA5E9)),
),
],
),
),
);
}
}
/// Calendar view showing tickets by deadline.
class _CalendarView extends StatelessWidget {
final List<Ticket> tickets;
final List<Ticket> allTickets;
final DateTime currentMonth;
final ValueChanged<DateTime> onMonthChanged;
final ValueChanged<Ticket> onTicketTap;
final List<TicketStatus> statuses;
final List<TicketPriority> priorities;
const _CalendarView({
required this.tickets,
required this.allTickets,
required this.currentMonth,
required this.onMonthChanged,
required this.onTicketTap,
required this.statuses,
required this.priorities,
});
@override
Widget build(BuildContext context) {
final year = currentMonth.year;
final month = currentMonth.month;
final firstDay = DateTime(year, month, 1);
final lastDay = DateTime(year, month + 1, 0);
final startWeekday = firstDay.weekday; // 1=Mon, 7=Sun
final daysInMonth = lastDay.day;
final today = DateTime.now();
// Build a map of date -> tickets
final dateTickets = <int, List<Ticket>>{};
for (final t in tickets) {
if (t.deadlineFrom != null &&
t.deadlineFrom!.year == year && t.deadlineFrom!.month == month) {
dateTickets.putIfAbsent(t.deadlineFrom!.day, () => []).add(t);
}
if (t.deadlineTo != null &&
t.deadlineTo!.year == year && t.deadlineTo!.month == month) {
final dayTo = t.deadlineTo!.day;
if (t.deadlineFrom == null ||
t.deadlineFrom!.day != dayTo ||
t.deadlineFrom!.month != month ||
t.deadlineFrom!.year != year) {
dateTickets.putIfAbsent(dayTo, () => []).add(t);
}
}
if (t.deadlineFrom != null && t.deadlineTo != null &&
t.deadlineFrom!.isBefore(firstDay) && t.deadlineTo!.isAfter(lastDay)) {
dateTickets.putIfAbsent(1, () => []).add(t);
}
}
return Column(
children: [
// Month header
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.chevron_left, color: GodTheme.textPrimary),
onPressed: () => onMonthChanged(DateTime(year, month - 1)),
),
GestureDetector(
onTap: () => onMonthChanged(DateTime(today.year, today.month)),
child: Text(
DateFormat('MMMM yyyy').format(currentMonth),
style: const TextStyle(
color: GodTheme.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
icon: const Icon(Icons.chevron_right, color: GodTheme.textPrimary),
onPressed: () => onMonthChanged(DateTime(year, month + 1)),
),
],
),
),
// Day headers
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
.map((d) => Expanded(
child: Center(
child: Text(d,
style: const TextStyle(
color: GodTheme.textMuted,
fontSize: 11,
fontWeight: FontWeight.w600,
)),
),
))
.toList(),
),
),
const SizedBox(height: 4),
// Calendar grid
Expanded(
child: GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
childAspectRatio: 0.7,
mainAxisSpacing: 2,
crossAxisSpacing: 2,
),
// Pad to complete weeks (rows of 7)
itemCount: (((startWeekday - 1) + daysInMonth + 6) ~/ 7) * 7,
itemBuilder: (context, index) {
if (index < startWeekday - 1) {
return const SizedBox.shrink();
}
final day = index - (startWeekday - 1) + 1;
if (day > daysInMonth) return const SizedBox.shrink();
final isToday = today.year == year && today.month == month && today.day == day;
final dayTicketList = dateTickets[day] ?? [];
return GestureDetector(
onTap: dayTicketList.isNotEmpty
? () => _showDayTickets(context, day, dayTicketList)
: null,
child: Container(
margin: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: isToday
? _kPrimaryOp01
: dayTicketList.isNotEmpty
? GodTheme.surfaceLight
: null,
borderRadius: BorderRadius.circular(6),
border: isToday
? Border.all(color: _kPrimaryOp03)
: null,
),
child: Column(
children: [
const SizedBox(height: 2),
Text(
'$day',
style: TextStyle(
color: isToday ? GodTheme.primary : GodTheme.textPrimary,
fontSize: 12,
fontWeight: isToday ? FontWeight.w700 : FontWeight.w400,
),
),
const SizedBox(height: 2),
...dayTicketList.take(3).map((t) {
final sc = statusColorFromList(t.status, statuses);
return Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
decoration: BoxDecoration(
color: sc.withOpacity(0.2),
borderRadius: BorderRadius.circular(2),
),
child: Text(
t.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: sc,
fontSize: 7,
fontWeight: FontWeight.w600,
),
),
);
}),
if (dayTicketList.length > 3)
Text(
'+${dayTicketList.length - 3}',
style: const TextStyle(
color: GodTheme.textMuted,
fontSize: 8,
),
),
],
),
),
);
},
),
),
],
);
}
void _showDayTickets(BuildContext context, int day, List<Ticket> dayTickets) {
showModalBottomSheet(
context: context,
backgroundColor: GodTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40, height: 4,
decoration: BoxDecoration(
color: _kTextMutedOp03,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'${DateFormat('MMMM').format(currentMonth)} $day${dayTickets.length} ticket${dayTickets.length != 1 ? 's' : ''}',
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 16,
color: GodTheme.textPrimary,
),
),
),
const SizedBox(height: 8),
...dayTickets.map((t) {
final sc = statusColorFromList(t.status, statuses);
return ListTile(
leading: Container(
width: 36, height: 36,
decoration: BoxDecoration(
color: sc.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(_iconForStatus(t.status), color: sc, size: 18),
),
title: Text(t.title, maxLines: 2, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, color: GodTheme.textPrimary)),
subtitle: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: sc.withOpacity(0.1),
borderRadius: BorderRadius.circular(3),
),
child: Text(t.status.replaceAll('_', ' '),
style: TextStyle(color: sc, fontSize: 10, fontWeight: FontWeight.w600)),
),
if (t.priority != null) ...[
const SizedBox(width: 4),
Builder(builder: (_) {
final pc = priorityColorFromList(t.priority!, priorities);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: pc.withOpacity(0.1),
borderRadius: BorderRadius.circular(3),
),
child: Text(t.priority!,
style: TextStyle(color: pc, fontSize: 10)),
);
}),
],
],
),
trailing: const Icon(Icons.info_outline, size: 18, color: GodTheme.textMuted),
onTap: () {
Navigator.pop(ctx);
onTicketTap(t);
},
);
}),
],
),
),
);
}
}
/// Single ticket tile with status change, deadline display, and chat button.
class _TicketTile extends ConsumerWidget {
final Ticket ticket;
const _TicketTile({super.key, required this.ticket});
/// Get status color from dynamic list.
Color _dynStatusColor(WidgetRef ref) {
final statuses = ref.read(ticketStatusesProvider).valueOrNull ?? List<TicketStatus>.from(defaultTicketStatuses);
return statusColorFromList(ticket.status, statuses);
}
/// Get priority color from dynamic list.
Color _dynPriorityColor(String priority, WidgetRef ref) {
final priorities = ref.read(ticketPrioritiesProvider).valueOrNull ?? List<TicketPriority>.from(defaultTicketPriorities);
return priorityColorFromList(priority, priorities);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasDeadline = ticket.deadlineFrom != null || ticket.deadlineTo != null;
final sColor = _dynStatusColor(ref);
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: GestureDetector(
onTap: () => _showStatusPicker(context, ref),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: ticket.color != null
? _parseHexColor(ticket.color!).withOpacity(0.15)
: sColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(10),
border: ticket.color != null
? Border.all(color: _parseHexColor(ticket.color!).withOpacity(0.3), width: 1)
: null,
),
child: ticket.emoji != null
? Center(child: Text(ticket.emoji!, style: const TextStyle(fontSize: 20)))
: Icon(
_iconForStatus(ticket.status),
color: sColor,
size: 20,
),
),
),
title: Row(
children: [
if (ticket.emoji != null) ...[
Text(ticket.emoji!, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 6),
],
Expanded(
child: Text(
ticket.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
GestureDetector(
onTap: () => _showStatusPicker(context, ref),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: sColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: sColor.withOpacity(0.3), width: 0.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
ticket.status.replaceAll('_', ' '),
style: TextStyle(
color: sColor,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 2),
Icon(Icons.arrow_drop_down, size: 12, color: sColor),
],
),
),
),
if (ticket.type != null)
_buildChip(ticket.type!, const Color(0xFF6366F1)),
if (ticket.priority != null)
GestureDetector(
onTap: () => _showPriorityPicker(context, ref),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: _dynPriorityColor(ticket.priority!, ref).withOpacity(0.1),
borderRadius: BorderRadius.circular(3),
border: Border.all(color: _dynPriorityColor(ticket.priority!, ref).withOpacity(0.3), width: 0.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
ticket.priority!,
style: TextStyle(color: _dynPriorityColor(ticket.priority!, ref), fontSize: 10, fontWeight: FontWeight.w600),
),
const SizedBox(width: 2),
Icon(Icons.arrow_drop_down, size: 12, color: _dynPriorityColor(ticket.priority!, ref)),
],
),
),
),
if (ticket.phase != null)
_buildChip(ticket.phase!, const Color(0xFF14B8A6)),
],
),
// Deadline display
if (hasDeadline)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
Icon(Icons.schedule, size: 12,
color: _isOverdue(ticket)
? const Color(0xFFEF4444)
: const Color(0xFF0EA5E9)),
const SizedBox(width: 3),
Text(
_formatDeadlineRange(ticket),
style: TextStyle(
color: _isOverdue(ticket)
? const Color(0xFFEF4444)
: const Color(0xFF0EA5E9),
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
],
),
),
if (ticket.assignee != null || ticket.adrRef != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
if (ticket.assignee != null) ...[
const Icon(Icons.person_outline, size: 12, color: GodTheme.textMuted),
const SizedBox(width: 3),
Flexible(
child: Text(
ticket.assignee!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: GodTheme.textSecondary, fontSize: 11),
),
),
],
if (ticket.adrRef != null) ...[
const SizedBox(width: 8),
Icon(Icons.link, size: 12, color: _kPrimaryOp07),
const SizedBox(width: 3),
Flexible(
child: Text(
ticket.adrRef!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: _kPrimaryOp07, fontSize: 11),
),
),
],
],
),
),
],
),
trailing: IconButton(
icon: const Icon(Icons.chat_bubble_outline, size: 18),
color: GodTheme.primary,
tooltip: 'Open chat',
onPressed: () => _openTicketChat(context, ref),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
),
onTap: () => _showTicketDetail(context),
);
}
bool _isOverdue(Ticket t) {
if (t.deadlineTo == null) return false;
if (t.status.toLowerCase() == 'done' || t.status.toLowerCase() == 'completed') return false;
return t.deadlineTo!.isBefore(DateTime.now());
}
String _formatDeadlineRange(Ticket t) {
final df = DateFormat('dd.MM');
if (t.deadlineFrom != null && t.deadlineTo != null) {
return '${df.format(t.deadlineFrom!)} - ${df.format(t.deadlineTo!)}';
}
if (t.deadlineFrom != null) return 'Start: ${df.format(t.deadlineFrom!)}';
if (t.deadlineTo != null) {
if (_isOverdue(t)) return 'Overdue: ${df.format(t.deadlineTo!)}';
return 'Due: ${df.format(t.deadlineTo!)}';
}
return '';
}
Widget _buildChip(String label, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(3),
),
child: Text(
label,
style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w500),
),
);
}
Color _parseHexColor(String hex) {
final clean = hex.replaceAll('#', '');
if (clean.length == 6) return Color(int.parse('FF$clean', radix: 16));
if (clean.length == 8) return Color(int.parse(clean, radix: 16));
return const Color(0xFF6B7280);
}
void _showStatusPicker(BuildContext context, WidgetRef ref) {
final statuses = ref.read(ticketStatusesProvider).valueOrNull ?? List<TicketStatus>.from(defaultTicketStatuses);
showModalBottomSheet(
context: context,
backgroundColor: GodTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: const Text('Change Status',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16,
color: GodTheme.textPrimary)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(ticket.title,
maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: GodTheme.textMuted, fontSize: 13)),
),
const SizedBox(height: 8),
const Divider(height: 1),
...statuses.map((s) {
final isSelected = s.name.toLowerCase() ==
ticket.status.toLowerCase().replaceAll('_', ' ');
return ListTile(
leading: Container(
width: 32, height: 32,
decoration: BoxDecoration(
color: s.color.withOpacity(isSelected ? 0.25 : 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(_iconForStatus(s.name), color: s.color, size: 18),
),
title: Text(s.name.replaceAll('_', ' ').toUpperCase(),
style: TextStyle(
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected ? s.color : GodTheme.textPrimary,
fontSize: 14,
)),
trailing: isSelected
? Icon(Icons.check_circle, color: s.color, size: 20)
: null,
onTap: () {
Navigator.pop(ctx);
if (!isSelected) _updateTicketStatus(context, ref, s.name);
},
);
}),
const SizedBox(height: 8),
],
),
),
);
}
Future<void> _updateTicketStatus(BuildContext context, WidgetRef ref, String newStatus) async {
try {
final apiClient = ref.read(apiClientProvider);
final tableId = ticket.tableId ?? 1708;
await apiClient.put('/tables/$tableId/rows/${ticket.id}',
data: {'data': {'status': newStatus}});
ref.read(ticketsProvider.notifier).refresh();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Status updated to ${newStatus.replaceAll('_', ' ')}'),
duration: const Duration(seconds: 2)),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update status: $e'),
backgroundColor: GodTheme.error),
);
}
}
}
void _showPriorityPicker(BuildContext context, WidgetRef ref) {
final priorities = ref.read(ticketPrioritiesProvider).valueOrNull ?? List<TicketPriority>.from(defaultTicketPriorities);
showModalBottomSheet(
context: context,
backgroundColor: GodTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: const Text('Change Priority',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16,
color: GodTheme.textPrimary)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(ticket.title,
maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: GodTheme.textMuted, fontSize: 13)),
),
const SizedBox(height: 8),
const Divider(height: 1),
...priorities.map((p) {
final isSelected = p.name.toLowerCase() ==
(ticket.priority?.toLowerCase() ?? '');
return ListTile(
leading: Container(
width: 32, height: 32,
decoration: BoxDecoration(
color: p.color.withOpacity(isSelected ? 0.25 : 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.flag, color: p.color, size: 18),
),
title: Text(p.name.toUpperCase(),
style: TextStyle(
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected ? p.color : GodTheme.textPrimary,
fontSize: 14,
)),
trailing: isSelected
? Icon(Icons.check_circle, color: p.color, size: 20)
: null,
onTap: () {
Navigator.pop(ctx);
if (!isSelected) _updateTicketPriority(context, ref, p.name);
},
);
}),
const SizedBox(height: 8),
],
),
),
);
}
Future<void> _updateTicketPriority(BuildContext context, WidgetRef ref, String newPriority) async {
try {
final apiClient = ref.read(apiClientProvider);
final tableId = ticket.tableId ?? 1708;
await apiClient.put('/tables/$tableId/rows/${ticket.id}',
data: {'data': {'priority': newPriority}});
ref.read(ticketsProvider.notifier).refresh();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Priority updated to $newPriority'),
duration: const Duration(seconds: 2)),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update priority: $e'),
backgroundColor: GodTheme.error),
);
}
}
}
Future<void> _openTicketChat(BuildContext context, WidgetRef ref) async {
try {
final conv = await ref.read(conversationsProvider.notifier).create(
title: '#${ticket.id}: ${ticket.title}',
);
if (conv != null && context.mounted) {
context.go('/chat/${conv.id}');
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to create chat: $e'),
backgroundColor: GodTheme.error),
);
}
}
}
void _showTicketDetail(BuildContext context) {
showModalBottomSheet(
context: context,
backgroundColor: GodTheme.surface,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => DraggableScrollableSheet(
initialChildSize: 0.45,
minChildSize: 0.2,
maxChildSize: 0.8,
expand: false,
builder: (_, scrollController) => SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.all(20),
child: _TicketDetailContent(ticket: ticket),
),
),
);
}
}
/// Ticket detail content (shared between tile and calendar tap).
class _TicketDetailContent extends ConsumerWidget {
final Ticket ticket;
const _TicketDetailContent({required this.ticket});
@override
Widget build(BuildContext context, WidgetRef ref) {
final statuses = ref.read(ticketStatusesProvider).valueOrNull ?? List<TicketStatus>.from(defaultTicketStatuses);
final priorities = ref.read(ticketPrioritiesProvider).valueOrNull ?? List<TicketPriority>.from(defaultTicketPriorities);
final sColor = statusColorFromList(ticket.status, statuses);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40, height: 4,
decoration: BoxDecoration(
color: _kTextMutedOp03,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: sColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(ticket.status.replaceAll('_', ' ').toUpperCase(),
style: TextStyle(color: sColor, fontSize: 11, fontWeight: FontWeight.w700)),
),
const SizedBox(width: 8),
Text('#${ticket.id}', style: const TextStyle(color: GodTheme.textMuted, fontSize: 13)),
const Spacer(),
if (ticket.priority != null)
Text(ticket.priority!, style: const TextStyle(color: GodTheme.textMuted, fontSize: 12)),
],
),
const SizedBox(height: 12),
Row(
children: [
if (ticket.emoji != null) ...[
Text(ticket.emoji!, style: const TextStyle(fontSize: 24)),
const SizedBox(width: 8),
],
Expanded(
child: Text(ticket.title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: GodTheme.textPrimary)),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8, runSpacing: 6,
children: [
if (ticket.type != null)
_detailField('Type', ticket.type!, const Color(0xFF6366F1)),
if (ticket.priority != null)
_detailField('Priority', ticket.priority!, priorityColorFromList(ticket.priority!, priorities)),
if (ticket.phase != null)
_detailField('Phase', ticket.phase!, const Color(0xFF14B8A6)),
if (ticket.adrRef != null)
_detailField('ADR', ticket.adrRef!, GodTheme.primary),
],
),
// Deadline section
if (ticket.deadlineFrom != null || ticket.deadlineTo != null) ...[
const SizedBox(height: 12),
Row(
children: [
const Icon(Icons.schedule, size: 16, color: Color(0xFF0EA5E9)),
const SizedBox(width: 6),
if (ticket.deadlineFrom != null)
Text('From: ${DateFormat('dd.MM.yyyy').format(ticket.deadlineFrom!)} ',
style: const TextStyle(color: Color(0xFF0EA5E9), fontSize: 13)),
if (ticket.deadlineTo != null)
Text('To: ${DateFormat('dd.MM.yyyy').format(ticket.deadlineTo!)}',
style: const TextStyle(color: Color(0xFF0EA5E9), fontSize: 13)),
],
),
],
if (ticket.assignee != null) ...[
const SizedBox(height: 12),
Row(children: [
const Icon(Icons.person_outline, size: 16, color: GodTheme.textMuted),
const SizedBox(width: 6),
Text('Assigned: ${ticket.assignee!}',
style: const TextStyle(color: GodTheme.textSecondary, fontSize: 13)),
]),
],
if (ticket.description != null) ...[
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 12),
Text(ticket.description!,
style: const TextStyle(color: GodTheme.textSecondary, fontSize: 14, height: 1.5)),
],
const SizedBox(height: 20),
// Chat button
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () async {
Navigator.pop(context);
try {
final conv = await ref.read(conversationsProvider.notifier).create(
title: '#${ticket.id}: ${ticket.title}',
);
if (conv != null && context.mounted) {
context.go('/chat/${conv.id}');
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка: $e'), backgroundColor: GodTheme.error),
);
}
}
},
icon: const Icon(Icons.chat_bubble_outline, size: 18),
label: const Text('Открыть чат'),
style: ElevatedButton.styleFrom(
backgroundColor: GodTheme.primary,
foregroundColor: Colors.white,
),
),
),
const SizedBox(height: 12),
],
);
}
Widget _detailField(String label, String value, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.08),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withOpacity(0.2), width: 0.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: TextStyle(color: color.withOpacity(0.6), fontSize: 9, fontWeight: FontWeight.w600)),
Text(value, style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600)),
],
),
);
}
}
// ============================================================================
// Todo View — checklist mode with dopamine feedback
// ============================================================================
/// Todo view that shows tickets as a checklist with completion animations.
class _TodoView extends ConsumerStatefulWidget {
final List<Ticket> tickets;
final List<TicketStatus> statuses;
final List<TicketPriority> priorities;
final TodoViewSettings todoSettings;
final ValueChanged<Ticket> onTicketTap;
final ValueChanged<Ticket>? onTicketChat;
const _TodoView({
required this.tickets,
required this.statuses,
required this.priorities,
required this.todoSettings,
required this.onTicketTap,
this.onTicketChat,
});
@override
ConsumerState<_TodoView> createState() => _TodoViewState();
}
class _TodoViewState extends ConsumerState<_TodoView> {
/// Tracks tickets that were just completed (for animation).
final Set<int> _justCompleted = {};
List<Ticket> _filterForTodoView(List<Ticket> tickets) {
final visible = widget.todoSettings.visibleStatuses;
if (visible.isEmpty) return tickets;
return tickets.where((t) {
final normalized = t.status.toLowerCase().replaceAll('_', ' ');
return visible.any((v) => v.toLowerCase() == normalized);
}).toList();
}
bool _isDone(Ticket t) {
final doneStatus = widget.todoSettings.doneStatus.toLowerCase().replaceAll('_', ' ');
final ticketStatus = t.status.toLowerCase().replaceAll('_', ' ');
// Also match "done" and "completed" as final
return ticketStatus == doneStatus ||
ticketStatus == 'done' ||
ticketStatus == 'completed' ||
ticketStatus == 'rejected';
}
Future<void> _markDone(Ticket ticket) async {
final doneStatus = widget.todoSettings.doneStatus;
try {
HapticFeedback.mediumImpact();
setState(() => _justCompleted.add(ticket.id));
final apiClient = ref.read(apiClientProvider);
final tableId = ticket.tableId ?? 1708;
await apiClient.put('/tables/$tableId/rows/${ticket.id}',
data: {'data': {'status': doneStatus}});
// Show dopamine snackbar
if (mounted) {
_showDopamineOverlay(context);
}
// Wait for animation then refresh
await Future.delayed(const Duration(milliseconds: 800));
ref.read(ticketsProvider.notifier).refresh();
} catch (e) {
setState(() => _justCompleted.remove(ticket.id));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка: $e'), backgroundColor: GodTheme.error),
);
}
}
}
Future<void> _updateStatus(Ticket ticket, String newStatus) async {
try {
HapticFeedback.lightImpact();
final apiClient = ref.read(apiClientProvider);
final tableId = ticket.tableId ?? 1708;
await apiClient.put('/tables/$tableId/rows/${ticket.id}',
data: {'data': {'status': newStatus}});
ref.read(ticketsProvider.notifier).refresh();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Статус: ${newStatus.replaceAll('_', ' ')}'),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка: $e'), backgroundColor: GodTheme.error),
);
}
}
}
void _showDopamineOverlay(BuildContext context) {
final phrases = [
'Отлично!',
'Молодец!',
'Супер!',
'Красавчик!',
'Так держать!',
'Огонь!',
'Круто!',
'Мега!',
];
final phrase = phrases[Random().nextInt(phrases.length)];
final overlay = Overlay.of(context);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (ctx) => _DopamineOverlay(
phrase: phrase,
onDone: () => entry.remove(),
),
);
overlay.insert(entry);
}
void _showStatusPickerForTodo(BuildContext context, Ticket ticket) {
HapticFeedback.lightImpact();
showModalBottomSheet(
context: context,
backgroundColor: GodTheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: const Text('Изменить статус',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16,
color: GodTheme.textPrimary)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(ticket.title,
maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: GodTheme.textMuted, fontSize: 13)),
),
const SizedBox(height: 8),
const Divider(height: 1),
...widget.statuses.map((s) {
final isSelected = s.name.toLowerCase() ==
ticket.status.toLowerCase().replaceAll('_', ' ');
return ListTile(
leading: Container(
width: 32, height: 32,
decoration: BoxDecoration(
color: s.color.withOpacity(isSelected ? 0.25 : 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(_iconForStatus(s.name), color: s.color, size: 18),
),
title: Text(s.name.replaceAll('_', ' ').toUpperCase(),
style: TextStyle(
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected ? s.color : GodTheme.textPrimary,
fontSize: 14,
)),
trailing: isSelected
? Icon(Icons.check_circle, color: s.color, size: 20)
: null,
onTap: () {
Navigator.pop(ctx);
if (!isSelected) _updateStatus(ticket, s.name);
},
);
}),
const SizedBox(height: 8),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final filtered = _filterForTodoView(widget.tickets);
if (filtered.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.task_alt, size: 64,
color: _kSuccessOp03),
const SizedBox(height: 16),
const Text('Все задачи выполнены!',
style: TextStyle(color: GodTheme.textSecondary, fontSize: 16)),
const SizedBox(height: 8),
const Text('Или нет задач с выбранными статусами',
style: TextStyle(color: GodTheme.textMuted, fontSize: 13)),
],
),
);
}
// Separate done and not-done
final pending = filtered.where((t) => !_isDone(t)).toList();
final done = filtered.where((t) => _isDone(t)).toList();
return RefreshIndicator(
onRefresh: () => ref.read(ticketsProvider.notifier).refresh(),
child: ListView(
padding: const EdgeInsets.only(top: 8, bottom: 80),
children: [
// Pending count header
if (pending.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(
'Задачи (${pending.length})',
style: const TextStyle(
color: GodTheme.textMuted,
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
),
// Pending tickets
...pending.map((t) => _TodoTicketTile(
key: ValueKey('todo-${t.id}'),
ticket: t,
isDone: false,
isAnimating: _justCompleted.contains(t.id),
statuses: widget.statuses,
onCheck: () => _markDone(t),
onLongPressCheck: () => _showStatusPickerForTodo(context, t),
onTap: () => widget.onTicketTap(t),
onChat: widget.onTicketChat != null ? () => widget.onTicketChat!(t) : null,
)),
// Done section
if (done.isNotEmpty) ...[
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(
'Выполнено (${done.length})',
style: const TextStyle(
color: GodTheme.success,
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
),
...done.map((t) => _TodoTicketTile(
key: ValueKey('todo-done-${t.id}'),
ticket: t,
isDone: true,
isAnimating: false,
statuses: widget.statuses,
onCheck: () {}, // Already done
onLongPressCheck: () => _showStatusPickerForTodo(context, t),
onTap: () => widget.onTicketTap(t),
onChat: widget.onTicketChat != null ? () => widget.onTicketChat!(t) : null,
)),
],
],
),
);
}
}
/// Single todo ticket tile with animated checkbox.
class _TodoTicketTile extends StatefulWidget {
final Ticket ticket;
final bool isDone;
final bool isAnimating;
final List<TicketStatus> statuses;
final VoidCallback onCheck;
final VoidCallback onLongPressCheck;
final VoidCallback onTap;
final VoidCallback? onChat;
const _TodoTicketTile({
super.key,
required this.ticket,
required this.isDone,
required this.isAnimating,
required this.statuses,
required this.onCheck,
required this.onLongPressCheck,
required this.onTap,
this.onChat,
});
@override
State<_TodoTicketTile> createState() => _TodoTicketTileState();
}
class _TodoTicketTileState extends State<_TodoTicketTile>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnim;
late Animation<double> _strikeAnim;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_scaleAnim = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.15), weight: 30),
TweenSequenceItem(tween: Tween(begin: 1.15, end: 0.95), weight: 30),
TweenSequenceItem(tween: Tween(begin: 0.95, end: 1.0), weight: 40),
]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
_strikeAnim = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: const Interval(0.2, 0.8)),
);
}
@override
void didUpdateWidget(_TodoTicketTile old) {
super.didUpdateWidget(old);
if (widget.isAnimating && !old.isAnimating) {
_controller.forward();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Color _statusColor() {
return statusColorFromList(widget.ticket.status, widget.statuses);
}
@override
Widget build(BuildContext context) {
final sColor = _statusColor();
final isDone = widget.isDone || widget.isAnimating;
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: widget.isAnimating ? _scaleAnim.value : 1.0,
child: child,
);
},
child: InkWell(
onTap: widget.onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
decoration: BoxDecoration(
color: isDone
? _kSuccessOp005
: GodTheme.surfaceLight,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isDone
? _kSuccessOp02
: sColor.withOpacity(0.15),
width: 0.5,
),
),
child: Row(
children: [
// Checkbox
GestureDetector(
onTap: isDone ? null : widget.onCheck,
onLongPress: widget.onLongPressCheck,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: 28,
height: 28,
decoration: BoxDecoration(
color: isDone
? GodTheme.success
: sColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDone ? GodTheme.success : sColor.withOpacity(0.4),
width: isDone ? 0 : 1.5,
),
),
child: isDone
? const Icon(Icons.check_rounded, color: Colors.white, size: 18)
: null,
),
),
const SizedBox(width: 12),
// Content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title with strikethrough animation
AnimatedBuilder(
animation: _strikeAnim,
builder: (context, _) {
return Text(
widget.ticket.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isDone
? GodTheme.textMuted
: GodTheme.textPrimary,
decoration: isDone
? TextDecoration.lineThrough
: TextDecoration.none,
decorationColor: GodTheme.success,
decorationThickness: 2,
),
);
},
),
const SizedBox(height: 4),
// Meta row
Row(
children: [
// Status chip
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: sColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(3),
),
child: Text(
widget.ticket.status.replaceAll('_', ' '),
style: TextStyle(color: sColor, fontSize: 9, fontWeight: FontWeight.w600),
),
),
if (widget.ticket.priority != null) ...[
const SizedBox(width: 4),
Builder(builder: (_) {
final pc = priorityColorFromList(
widget.ticket.priority!,
widget.statuses.isEmpty
? defaultTicketPriorities
: // Use priorities from somewhere
defaultTicketPriorities);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: pc.withOpacity(0.1),
borderRadius: BorderRadius.circular(3),
),
child: Text(widget.ticket.priority!,
style: TextStyle(color: pc, fontSize: 9)),
);
}),
],
if (widget.ticket.deadlineTo != null) ...[
const SizedBox(width: 4),
Icon(Icons.schedule, size: 10,
color: widget.ticket.deadlineTo!.isBefore(DateTime.now()) && !isDone
? const Color(0xFFEF4444)
: GodTheme.textMuted),
const SizedBox(width: 2),
Text(
DateFormat('dd.MM').format(widget.ticket.deadlineTo!),
style: TextStyle(
fontSize: 10,
color: widget.ticket.deadlineTo!.isBefore(DateTime.now()) && !isDone
? const Color(0xFFEF4444)
: GodTheme.textMuted,
),
),
],
],
),
],
),
),
// Action buttons
const SizedBox(width: 4),
// Info button
GestureDetector(
onTap: widget.onTap,
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: _kTextMutedOp01,
borderRadius: BorderRadius.circular(6),
),
child: const Icon(Icons.info_outline, size: 16, color: GodTheme.textSecondary),
),
),
const SizedBox(width: 4),
// Chat button
if (widget.onChat != null)
GestureDetector(
onTap: widget.onChat,
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: _kPrimaryOp01,
borderRadius: BorderRadius.circular(6),
),
child: const Icon(Icons.chat_bubble_outline, size: 16, color: GodTheme.primary),
),
),
const SizedBox(width: 4),
// Status color indicator
Container(
width: 4,
height: 36,
decoration: BoxDecoration(
color: isDone ? GodTheme.success : sColor,
borderRadius: BorderRadius.circular(2),
),
),
],
),
),
),
);
}
}
/// AnimatedBuilder helper (since Flutter's is AnimatedBuilder).
/// This is just Flutter's AnimatedBuilder re-used for clarity.
/// Dopamine overlay — shows a congratulatory message with particles.
class _DopamineOverlay extends StatefulWidget {
final String phrase;
final VoidCallback onDone;
const _DopamineOverlay({required this.phrase, required this.onDone});
@override
State<_DopamineOverlay> createState() => _DopamineOverlayState();
}
class _DopamineOverlayState extends State<_DopamineOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnim;
late Animation<double> _opacityAnim;
late List<_Particle> _particles;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
_scaleAnim = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0.3, end: 1.2), weight: 25),
TweenSequenceItem(tween: Tween(begin: 1.2, end: 1.0), weight: 15),
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.0), weight: 30),
TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 30),
]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_opacityAnim = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 15),
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.0), weight: 55),
TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 30),
]).animate(_controller);
final rng = Random();
_particles = List.generate(12, (_) => _Particle(
angle: rng.nextDouble() * 2 * pi,
speed: 60 + rng.nextDouble() * 100,
color: [
GodTheme.success,
GodTheme.primary,
const Color(0xFFF59E0B),
const Color(0xFFEF4444),
const Color(0xFF8B5CF6),
const Color(0xFF0EA5E9),
][rng.nextInt(6)],
size: 4 + rng.nextDouble() * 6,
));
_controller.forward().then((_) => widget.onDone());
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final progress = _controller.value;
return IgnorePointer(
child: Stack(
children: [
// Center text
Center(
child: Opacity(
opacity: _opacityAnim.value,
child: Transform.scale(
scale: _scaleAnim.value,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: _kSuccessOp09,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: _kSuccessOp04,
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star_rounded, color: Colors.white, size: 24),
const SizedBox(width: 8),
Text(widget.phrase,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
),
),
],
),
),
),
),
),
// Particles
...(_particles.map((p) {
final dx = cos(p.angle) * p.speed * progress;
final dy = sin(p.angle) * p.speed * progress - 30 * progress;
final size = MediaQuery.of(context).size;
return Positioned(
left: size.width / 2 + dx - p.size / 2,
top: size.height / 2 + dy - p.size / 2,
child: Opacity(
opacity: (1 - progress).clamp(0.0, 1.0),
child: Container(
width: p.size,
height: p.size,
decoration: BoxDecoration(
color: p.color,
shape: BoxShape.circle,
),
),
),
);
})),
],
),
);
},
);
}
}
class _Particle {
final double angle;
final double speed;
final Color color;
final double size;
_Particle({
required this.angle,
required this.speed,
required this.color,
required this.size,
});
}