return Scaffold(
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
// Используем метод для построения заголовка
_buildHomeHeader(context, username, unViewedCounts, opportunitiesState),
// Добавляем SliverOverlapAbsorber для корректной работы с вложенными списками
SliverOverlapAbsorber(
handle:
NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
pinned: true,
toolbarHeight: 36,
backgroundColor: Colors.white,
forceElevated: innerBoxIsScrolled,
flexibleSpace: FlexibleSpaceBar(
background: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
child: Container(
color: Colors.white,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Возможности',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
],
),
),
),
),
bottom: TabBar(
controller: _tabController,
labelColor: Colors.blue,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.blue,
isScrollable: true,
tabs: [
// Используем кастомные табы с индикаторами непросмотренных элементов
BadgedTab(
text: 'Стипендии',
unviewedCount: unViewedCounts.isNotEmpty ? unViewedCounts[0] : 0,
),
BadgedTab(
text: 'Стажировки',
unviewedCount: unViewedCounts.length > 1 ? unViewedCounts[1] : 0,
),
BadgedTab(
text: 'Олимпиады',
unviewedCount: unViewedCounts.length > 2 ? unViewedCounts[2] : 0,
),
// Tab(text: 'Избранные'), // Для избранных индикатор не нужен
],
),
),
),
];
},
body: TabBarView(
controller: _tabController,
children: tabs.keys.map((String name) {
// Map tab name to opportunity type for API calls
String? opportunityType;
switch (name) {
case 'Стипендии':
opportunityType = 'grant';
break;
case 'Стажировки':
opportunityType = 'internship';
break;
case 'Олимпиады':
opportunityType = 'olympiad';
break;
}
return SafeArea(
top: false,
bottom: false,
child: Builder(
builder: (BuildContext context) {
return EasyRefresh(
header: const MaterialHeader(
position: IndicatorPosition.locator,
),
footer: const ClassicFooter(
position: IndicatorPosition.locator,
processedDuration: Duration(milliseconds: 400),
),
onRefresh: () async {
// Обновляем данные в зависимости от вкладки
context.read<OpportunitiesBloc>().add(RefreshOpportunities());
},
onLoad: () async {
// Calculate current offset based on list length
final currentList = tabs[name]!;
final offset = currentList.length;
// Для других вкладок используем OpportunitiesBloc
context.read<OpportunitiesBloc>().add(FetchMoreOpportunities(
type: opportunityType,
offset: offset,
));
},
child: CustomScrollView(
key: PageStorageKey<String>(name),
slivers: <Widget>[
// Overlap injector for nested scrolling
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
// Header locator for pull-to-refresh
const HeaderLocator.sliver(),
tabs[name]!.isNotEmpty
? SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final opportunity = tabs[name]![index];
return OpportunityListItem(
data: opportunity,
onFavoritePressed: (data) {
BlocProvider.of<FavoritesBloc>(context).add(
ToggleFavoriteStatus(opportunity: data)
);
BlocProvider.of<OpportunitiesBloc>(context).add(ToggleFavorite(opportunity: data));
print('Favorite toggled for ${data.title}');
},
onReturnRefresh: () {
// Обновляем оба блока при возвращении на экран
BlocProvider.of<OpportunitiesBloc>(context).add(RefreshWOAnimation());
BlocProvider.of<FavoritesBloc>(context).add(LoadFavorites());
},
onMarkViewed: (data){
BlocProvider.of<OpportunitiesBloc>(context).add(MarkOpportunityAsViewed(
eventId: data.eventId,
category: data.typesEvent
));
},
);
},
// Use correct length based on the current tab
childCount: tabs[name]!.length,
),
)
: const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox, color: Colors.grey, size: 60),
SizedBox(height: 16),
Text('Список пуст'),
],
),
),
),
// Footer locator for infinite loading
const FooterLocator.sliver(),
],
),
);
},
),
);
}).toList(),
),
),
);
}
// Метод для построения заголовка главного экрана с приветствием, уведомлениями, поиском и событиями
Widget _buildHomeHeader(BuildContext context, String username, List<int> unViewedCounts, OpportunitiesLoaded opportunitiesState) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Секция приветствия и кнопка уведомлений
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Привет, $username!',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const Text(
'Найди своё будущее вместе с нами!',
style: TextStyle(
color: Colors.grey,
fontSize: 14,
),
),
],
),
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
elevation: 2,
child: InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => UnviewedScreen(),
),
);
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(10),
child: Badge(
// Показываем только маркер без цифры внутри
smallSize: 8,
backgroundColor: Colors.red,
// Показываем бейдж только если есть непросмотренные элементы
isLabelVisible: unViewedCounts.fold(0, (total, count) => total + count) > 0,
child: const Icon(
Icons.notifications,
color: Colors.grey,
size: 24,
),
),
),
),
),
],
),
),
// Поисковая строка с кнопкой фильтрации
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const SearchScreen(),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
height: 48, // Фиксированная высота для поисковой строки
decoration: BoxDecoration(
color: Colors.white, // Фон контейнера
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
// Цвет тени
blurRadius: 12,
// Размытие
spreadRadius: 2,
// Радиус распространения
offset: const Offset(0, 4), // Смещение вниз
),
],
),
child: const TextField(
enabled: false,
// Отключаем редактирование, чтобы работал только onTap
decoration: InputDecoration(
icon: Icon(Icons.search, color: Colors.grey),
hintText: 'Искать',
border: InputBorder.none,
),
),
),
),
),
const SizedBox(width: 8),
Container(
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
// Цвет тени
blurRadius: 12,
// Размытие
spreadRadius: 2,
// Радиус распространения
offset: const Offset(0, 4), // Смещение вниз
),
],
),
child: IconButton(
icon: const Icon(Icons.tune),
onPressed: () {
// Показываем диалог фильтрации или переходим на экран поиска
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const SearchScreen(),
),
);
},
color: Colors.white,
),
),
],
),
),
// Секция "События" с горизонтальным списком
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Мероприятия',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
TextButton(
onPressed: () {
// Переход в EventsScreen
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => EventsScreen(),
),
);
},
child: const Text(
'Смотреть все',
style: TextStyle(
color: Colors.blue,
),
),
),
],
),
const SizedBox(height: 8),
SizedBox(
height: 180,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: opportunitiesState.events.length,
itemBuilder: (context, index) {
final event = opportunitiesState.events[index];
return OpportunityCard(
event: event,
onReturnRefresh: () {
BlocProvider.of<EventBloc>(context).add(RefreshWOAnimationEvent());
},
isHorizontal: true,
);
},
),
),
],
),
),
],
),
),
);
}