|
|
|
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
|
|
|
|
import 'package:doublefeel_flutter/core/services/user_state_service.dart';
|
|
|
|
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:get/get.dart';
|
|
|
|
import 'package:intl/intl.dart';
|
|
|
|
import 'package:table_calendar/table_calendar.dart';
|
|
|
|
|
|
|
|
import '../../controllers/home_controller.dart';
|
|
|
|
import '../../controllers/today_controller.dart';
|
|
|
|
import '../../widgets/today/today_date_strip.dart';
|
|
|
|
import '../../widgets/today/today_health_data_auth_card.dart';
|
|
|
|
import '../../widgets/today/today_hrv_ad_banner.dart';
|
|
|
|
import '../../widgets/today/today_hrv_number_card.dart';
|
|
|
|
import '../../widgets/today/today_hrv_chart_card.dart';
|
|
|
|
import '../../widgets/today/today_actions_card.dart';
|
|
|
|
import '../../widgets/today/today_sleep_activity_cards.dart';
|
|
|
|
|
|
|
|
import '../../widgets/today/premium_card.dart';
|
|
|
|
|
|
|
|
class TodayTab extends GetView<TodayController> {
|
|
|
|
class TodayTab extends StatefulWidget {
|
|
|
|
const TodayTab({super.key});
|
|
|
|
|
|
|
|
@override
|
|
|
|
State<TodayTab> createState() => _TodayTabState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _TodayTabState extends State<TodayTab> {
|
|
|
|
static const _bgColor = Color(0xFFF2F2F7);
|
|
|
|
static const _h1 = Color(0xFF0F0F11);
|
|
|
|
static const _topBarHeight = 48.0;
|
|
|
|
static const _weekCalendarHeight = 58.0;
|
|
|
|
static const _topContentHeight = _topBarHeight + _weekCalendarHeight;
|
|
|
|
static const _weekRowsHeight = 50.0;
|
|
|
|
static const _selectedDayWidth = 32.0;
|
|
|
|
|
|
|
|
final TodayController controller = Get.find<TodayController>();
|
|
|
|
final HomeController homeController = Get.find<HomeController>();
|
|
|
|
late final DateTime _firstSelectableDay;
|
|
|
|
late final DateTime _lastSelectableDay;
|
|
|
|
late final int _dayPageCount;
|
|
|
|
late final PageController _pageController;
|
|
|
|
late final Worker _selectedDateWorker;
|
|
|
|
late DateTime _focusedDay;
|
|
|
|
final ValueNotifier<double> _scrollOffset = ValueNotifier<double>(0);
|
|
|
|
|
|
|
|
@override
|
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
final today = DateUtils.dateOnly(DateTime.now());
|
|
|
|
_firstSelectableDay = DateTime(today.year - 1);
|
|
|
|
_lastSelectableDay = today;
|
|
|
|
_dayPageCount =
|
|
|
|
_lastSelectableDay.difference(_firstSelectableDay).inDays + 1;
|
|
|
|
|
|
|
|
final initialSelectedDay =
|
|
|
|
_clampSelectableDay(homeController.selectedDate.value);
|
|
|
|
_focusedDay = initialSelectedDay;
|
|
|
|
homeController.changeDate(initialSelectedDay);
|
|
|
|
_pageController = PageController(
|
|
|
|
initialPage: _pageIndexForDay(initialSelectedDay),
|
|
|
|
);
|
|
|
|
_selectedDateWorker = ever<DateTime>(
|
|
|
|
homeController.selectedDate,
|
|
|
|
_handleSelectedDateChanged,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void dispose() {
|
|
|
|
_selectedDateWorker.dispose();
|
|
|
|
_pageController.dispose();
|
|
|
|
_scrollOffset.dispose();
|
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final homeCtrl = Get.find<HomeController>();
|
|
|
|
final topPadding = MediaQuery.paddingOf(context).top;
|
|
|
|
final pinnedHeaderHeight = topPadding + _topContentHeight;
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
color: _bgColor,
|
|
|
|
child: Stack(
|
|
|
|
children: [
|
|
|
|
CustomScrollView(
|
|
|
|
physics: const ClampingScrollPhysics(),
|
|
|
|
slivers: [
|
|
|
|
SliverToBoxAdapter(
|
|
|
|
child: SizedBox(
|
|
|
|
height: 323,
|
|
|
|
child: PageView.builder(
|
|
|
|
itemCount: 10,
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
return Container(
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
gradient: LinearGradient(
|
|
|
|
begin: Alignment.topCenter,
|
|
|
|
end: Alignment.bottomCenter,
|
|
|
|
colors: [
|
|
|
|
const Color(0xFFC5B0FF),
|
|
|
|
const Color(0xFFF5F2FF)
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
child: Column(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
|
|
children: [
|
|
|
|
Text('Page $index'),
|
|
|
|
Text('Page $index'),
|
|
|
|
Text('Page $index'),
|
|
|
|
Text(
|
|
|
|
'Hi, 你今日的综合压力状态',
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
style: TextStyle(
|
|
|
|
color: const Color(0xFF78787D),
|
|
|
|
fontSize: 14,
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Text(
|
|
|
|
'状态正常',
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
style: TextStyle(
|
|
|
|
color: const Color(0xFF0F0F11),
|
|
|
|
fontSize: 28,
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Container(
|
|
|
|
width: 220,
|
|
|
|
height: 14,
|
|
|
|
margin: EdgeInsets.only(bottom: 36),
|
|
|
|
decoration: ShapeDecoration(
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
borderRadius: BorderRadius.circular(7)),
|
|
|
|
),
|
|
|
|
child: Stack(
|
|
|
|
children: [
|
|
|
|
Positioned(
|
|
|
|
left: 0,
|
|
|
|
top: 0,
|
|
|
|
child: Opacity(
|
|
|
|
opacity: 0.50,
|
|
|
|
child: Container(
|
|
|
|
width: 20,
|
|
|
|
height: 14,
|
|
|
|
decoration: ShapeDecoration(
|
|
|
|
color: const Color(0xFFFF5279),
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
borderRadius:
|
|
|
|
BorderRadius.circular(7)),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Positioned(
|
|
|
|
left: 24,
|
|
|
|
top: 0,
|
|
|
|
child: Opacity(
|
|
|
|
opacity: 0.50,
|
|
|
|
child: Container(
|
|
|
|
width: 20,
|
|
|
|
height: 14,
|
|
|
|
decoration: ShapeDecoration(
|
|
|
|
color: const Color(0xFFFF9A6E),
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
borderRadius:
|
|
|
|
BorderRadius.circular(7)),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Positioned(
|
|
|
|
left: 48,
|
|
|
|
top: 0,
|
|
|
|
child: Container(
|
|
|
|
width: 148,
|
|
|
|
height: 14,
|
|
|
|
decoration: ShapeDecoration(
|
|
|
|
color: const Color(0xFF7B9BFB),
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
borderRadius:
|
|
|
|
BorderRadius.circular(7)),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Positioned(
|
|
|
|
left: 200,
|
|
|
|
top: 0,
|
|
|
|
child: Opacity(
|
|
|
|
opacity: 0.50,
|
|
|
|
child: Container(
|
|
|
|
width: 20,
|
|
|
|
height: 14,
|
|
|
|
decoration: ShapeDecoration(
|
|
|
|
color: const Color(0xFF3BD39C),
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
borderRadius:
|
|
|
|
BorderRadius.circular(7)),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
)
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
// ── 各内容卡片 ──
|
|
|
|
SliverPadding(
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
|
|
sliver: SliverList(
|
|
|
|
delegate: SliverChildListDelegate([
|
|
|
|
Obx(() {
|
|
|
|
final isVip = Get.find<UserPreferencesStorage>()
|
|
|
|
.preferences
|
|
|
|
.value
|
|
|
|
.vipInfo
|
|
|
|
?.isVip ??
|
|
|
|
false;
|
|
|
|
if (isVip) return const SizedBox.shrink();
|
|
|
|
return const PremiumCard();
|
|
|
|
}),
|
|
|
|
// 1. HRV 表盘引导 Banner
|
|
|
|
const TodayHrvAdBanner(),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
|
|
|
// 3. HRV + 心率数字卡
|
|
|
|
const TodayHrvNumberCard(),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
|
|
|
// 4. HRV 趋势图
|
|
|
|
const TodayHrvChartCard(),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
Container(
|
|
|
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
|
|
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: Colors.white,
|
|
|
|
borderRadius: BorderRadius.circular(16),
|
|
|
|
),
|
|
|
|
child: Text(
|
|
|
|
'立即测量HRV',
|
|
|
|
style: TextStyle(
|
|
|
|
color: const Color(0xFF0F0F11),
|
|
|
|
fontSize: 16,
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
// 5. 每日行动
|
|
|
|
const TodayActionsCard(),
|
|
|
|
|
|
|
|
// 底部安全间距(留给 TabBar 高度)
|
|
|
|
const SizedBox(height: 180),
|
|
|
|
]),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
NotificationListener<ScrollNotification>(
|
|
|
|
onNotification: _handleScrollNotification,
|
|
|
|
child: PageView.builder(
|
|
|
|
controller: _pageController,
|
|
|
|
itemCount: _dayPageCount,
|
|
|
|
onPageChanged: _onDayPageChanged,
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
return _buildDayScrollView(context, pinnedHeaderHeight);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Positioned(
|
|
|
|
top: 0,
|
|
|
|
left: 0,
|
|
|
|
right: 0,
|
|
|
|
height: 106 + MediaQuery.of(context).padding.top,
|
|
|
|
child: Container(
|
|
|
|
color: const Color(0xFFDDD2FF).withValues(alpha: 0.9),
|
|
|
|
height: pinnedHeaderHeight,
|
|
|
|
child: ValueListenableBuilder<double>(
|
|
|
|
valueListenable: _scrollOffset,
|
|
|
|
builder: (context, offset, child) {
|
|
|
|
final opacity =
|
|
|
|
(offset / _topContentHeight).clamp(0.0, 1).toDouble();
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
color: const Color(0xFFDDD2FF).withValues(alpha: opacity),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Positioned(
|
|
|
|
top: MediaQuery.of(context).padding.top,
|
|
|
|
top: topPadding,
|
|
|
|
left: 0,
|
|
|
|
right: 0,
|
|
|
|
child: Column(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
children: [
|
|
|
|
Container(
|
|
|
|
height: 48,
|
|
|
|
padding:
|
|
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
|
|
child: Row(
|
|
|
|
children: [
|
|
|
|
Text(
|
|
|
|
'今日',
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
style: TextStyle(
|
|
|
|
color: const Color(0xFF0F0F11),
|
|
|
|
fontSize: 24,
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
),
|
|
|
|
child: Obx(
|
|
|
|
() => Column(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
children: [
|
|
|
|
_TopDateBar(
|
|
|
|
title: _dateTitle,
|
|
|
|
showBackToToday: !_isSelectedToday,
|
|
|
|
onBackToToday: _backToToday,
|
|
|
|
),
|
|
|
|
_buildWeekCalendar(),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _buildDayScrollView(
|
|
|
|
BuildContext context,
|
|
|
|
double pinnedHeaderHeight,
|
|
|
|
) {
|
|
|
|
return CustomScrollView(
|
|
|
|
primary: false,
|
|
|
|
physics: const ClampingScrollPhysics(),
|
|
|
|
slivers: [
|
|
|
|
SliverToBoxAdapter(
|
|
|
|
child: Container(
|
|
|
|
height: 323 + pinnedHeaderHeight,
|
|
|
|
decoration: const BoxDecoration(
|
|
|
|
gradient: LinearGradient(
|
|
|
|
begin: Alignment.topCenter,
|
|
|
|
end: Alignment.bottomCenter,
|
|
|
|
colors: [
|
|
|
|
Color(0xFFC5B0FF),
|
|
|
|
Color(0xFFF5F2FF),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
child: Container(
|
|
|
|
margin: EdgeInsets.only(top: pinnedHeaderHeight),
|
|
|
|
child: Column(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
|
|
children: [
|
|
|
|
Obx(
|
|
|
|
() => Text(
|
|
|
|
controller.stressSubtitle.value,
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
style: const TextStyle(
|
|
|
|
color: Color(0xFF78787D),
|
|
|
|
fontSize: 14,
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
),
|
|
|
|
const Spacer(),
|
|
|
|
// Pro 折扣徽章
|
|
|
|
Container(
|
|
|
|
width: 95,
|
|
|
|
height: 28.6,
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: const Color(0xFFFFDF51),
|
|
|
|
borderRadius: BorderRadius.circular(27.6),
|
|
|
|
),
|
|
|
|
child: Row(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
|
|
children: [
|
|
|
|
// Pro 皇冠图标
|
|
|
|
const Icon(
|
|
|
|
Icons.workspace_premium,
|
|
|
|
size: 20,
|
|
|
|
color: Color(0xFF0F0F11),
|
|
|
|
),
|
|
|
|
const SizedBox(width: 2),
|
|
|
|
const Text(
|
|
|
|
'20%优惠',
|
|
|
|
style: TextStyle(
|
|
|
|
fontSize: 14,
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
color: Color(0xFF0F0F11),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Obx(
|
|
|
|
() => Text(
|
|
|
|
controller.stressLabel.value,
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
style: const TextStyle(
|
|
|
|
color: Color(0xFF0F0F11),
|
|
|
|
fontSize: 28,
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
SliverPadding(
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
|
|
sliver: SliverList(
|
|
|
|
delegate: SliverChildListDelegate([
|
|
|
|
Obx(
|
|
|
|
() => controller.showHealthDataAuthCard.value
|
|
|
|
? TodayHealthDataAuthCard(
|
|
|
|
onAuthorizeTap: controller.requestHealthAuthorization,
|
|
|
|
)
|
|
|
|
: const SizedBox.shrink(),
|
|
|
|
),
|
|
|
|
Obx(() {
|
|
|
|
return Get.find<UserStateService>().isVip
|
|
|
|
? const SizedBox.shrink()
|
|
|
|
: const PremiumCard();
|
|
|
|
}),
|
|
|
|
const TodayHrvAdBanner(),
|
|
|
|
const TodayPartnerAdBanner(),
|
|
|
|
const TodayHrvNumberCard(),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
const TodayHrvChartCard(),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
const TodaySleepCard(),
|
|
|
|
const TodayActivityCard(),
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
Container(
|
|
|
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: Colors.white,
|
|
|
|
borderRadius: BorderRadius.circular(16),
|
|
|
|
),
|
|
|
|
child: const Text(
|
|
|
|
'立即测量HRV',
|
|
|
|
style: TextStyle(
|
|
|
|
color: Color(0xFF0F0F11),
|
|
|
|
fontSize: 16,
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Container(
|
|
|
|
alignment: Alignment.center,
|
|
|
|
margin: EdgeInsets.only(
|
|
|
|
bottom: 16 + MediaQuery.paddingOf(context).bottom,
|
|
|
|
top: 28,
|
|
|
|
),
|
|
|
|
child: Image.asset(
|
|
|
|
'assets/images/common/ic_bottom_slogan.png',
|
|
|
|
width: 223,
|
|
|
|
height: 35,
|
|
|
|
),
|
|
|
|
TableCalendar(
|
|
|
|
),
|
|
|
|
]),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _buildWeekCalendar() {
|
|
|
|
return SizedBox(
|
|
|
|
height: _weekCalendarHeight,
|
|
|
|
child: Padding(
|
|
|
|
padding: const EdgeInsets.fromLTRB(13, 4, 18, 4),
|
|
|
|
child: Row(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
Expanded(
|
|
|
|
child: SizedBox(
|
|
|
|
height: _weekRowsHeight,
|
|
|
|
child: TableCalendar(
|
|
|
|
calendarFormat: CalendarFormat.week,
|
|
|
|
headerVisible: false,
|
|
|
|
focusedDay: DateTime.now(),
|
|
|
|
firstDay: DateTime(2026),
|
|
|
|
lastDay: DateTime(2027),
|
|
|
|
daysOfWeekHeight: _weekRowsHeight / 2,
|
|
|
|
rowHeight: _weekRowsHeight / 2,
|
|
|
|
focusedDay: _focusedDay,
|
|
|
|
firstDay: _firstSelectableDay,
|
|
|
|
lastDay: _lastSelectableDay,
|
|
|
|
currentDay: _lastSelectableDay,
|
|
|
|
startingDayOfWeek: StartingDayOfWeek.monday,
|
|
|
|
availableCalendarFormats: const {
|
|
|
|
CalendarFormat.week: '',
|
|
|
|
},
|
|
|
|
availableGestures: AvailableGestures.horizontalSwipe,
|
|
|
|
enabledDayPredicate: (day) => !_isAfterToday(day),
|
|
|
|
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
|
|
|
|
onDaySelected: _onDaySelected,
|
|
|
|
onPageChanged: _onCalendarPageChanged,
|
|
|
|
calendarStyle: const CalendarStyle(
|
|
|
|
cellMargin: EdgeInsets.zero,
|
|
|
|
cellPadding: EdgeInsets.zero,
|
|
|
|
isTodayHighlighted: false,
|
|
|
|
outsideDaysVisible: true,
|
|
|
|
),
|
|
|
|
daysOfWeekStyle: const DaysOfWeekStyle(
|
|
|
|
decoration: BoxDecoration(),
|
|
|
|
),
|
|
|
|
calendarBuilders: CalendarBuilders(
|
|
|
|
dowBuilder: (context, day) => _buildWeekdayCell(day),
|
|
|
|
defaultBuilder: (context, day, focusedDay) =>
|
|
|
|
_buildDateCell(day),
|
|
|
|
disabledBuilder: (context, day, focusedDay) =>
|
|
|
|
_buildDateCell(day),
|
|
|
|
outsideBuilder: (context, day, focusedDay) =>
|
|
|
|
_buildDateCell(day),
|
|
|
|
selectedBuilder: (context, day, focusedDay) =>
|
|
|
|
_buildDateCell(day, selected: true),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const SizedBox(width: 3),
|
|
|
|
Container(
|
|
|
|
width: 1,
|
|
|
|
height: 20,
|
|
|
|
margin: const EdgeInsets.only(top: 15),
|
|
|
|
color: context.colors.textPrimary.withValues(alpha: 0.2),
|
|
|
|
),
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
Padding(
|
|
|
|
padding: const EdgeInsets.only(top: 15),
|
|
|
|
child: Image.asset(
|
|
|
|
'assets/images/common/ic_calendar.png',
|
|
|
|
width: 20,
|
|
|
|
height: 20,
|
|
|
|
color: context.colors.textPrimary.withValues(alpha: 0.6),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
bool _handleScrollNotification(ScrollNotification notification) {
|
|
|
|
if (notification.metrics.axis != Axis.vertical) return false;
|
|
|
|
|
|
|
|
final offset = notification.metrics.pixels.clamp(0.0, double.infinity);
|
|
|
|
if (_scrollOffset.value != offset) {
|
|
|
|
_scrollOffset.value = offset.toDouble();
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _buildWeekdayCell(DateTime day) {
|
|
|
|
final selected = isSameDay(_selectedDay, day);
|
|
|
|
|
|
|
|
return Center(
|
|
|
|
child: Container(
|
|
|
|
width: _selectedDayWidth,
|
|
|
|
height: _weekRowsHeight / 2,
|
|
|
|
alignment: Alignment.center,
|
|
|
|
decoration: selected
|
|
|
|
? BoxDecoration(
|
|
|
|
color: context.colors.primary,
|
|
|
|
borderRadius: const BorderRadius.vertical(
|
|
|
|
top: Radius.circular(22),
|
|
|
|
),
|
|
|
|
)
|
|
|
|
: null,
|
|
|
|
child: Text(
|
|
|
|
isSameDay(day, _lastSelectableDay) ? '今' : _weekdayText(day),
|
|
|
|
style: TextStyle(
|
|
|
|
color: _calendarTextColor(day, selected: selected),
|
|
|
|
fontSize: 12,
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
bool _isSameDay(DateTime a, DateTime b) =>
|
|
|
|
a.year == b.year && a.month == b.month && a.day == b.day;
|
|
|
|
Widget _buildDateCell(DateTime day, {bool selected = false}) {
|
|
|
|
return Center(
|
|
|
|
child: Container(
|
|
|
|
width: _selectedDayWidth,
|
|
|
|
height: _weekRowsHeight / 2,
|
|
|
|
alignment: Alignment.center,
|
|
|
|
decoration: selected
|
|
|
|
? BoxDecoration(
|
|
|
|
color: context.colors.primary,
|
|
|
|
borderRadius: BorderRadius.vertical(
|
|
|
|
bottom: Radius.circular(22),
|
|
|
|
),
|
|
|
|
)
|
|
|
|
: null,
|
|
|
|
child: Text(
|
|
|
|
'${day.day}',
|
|
|
|
style: TextStyle(
|
|
|
|
color: _calendarTextColor(day, selected: selected),
|
|
|
|
fontSize: 12,
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleSelectedDateChanged(DateTime selectedDate) {
|
|
|
|
final normalizedDay = _clampSelectableDay(selectedDate);
|
|
|
|
if (selectedDate != normalizedDay) {
|
|
|
|
homeController.changeDate(normalizedDay);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (mounted) {
|
|
|
|
setState(() {
|
|
|
|
_focusedDay = normalizedDay;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
_syncPagerToSelectedDay(normalizedDay);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _onDayPageChanged(int page) {
|
|
|
|
_selectDate(_dateForPage(page));
|
|
|
|
}
|
|
|
|
|
|
|
|
void _onDaySelected(DateTime selectedDay, DateTime focusedDay) {
|
|
|
|
_selectDate(selectedDay, focusedDay: focusedDay);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _onCalendarPageChanged(DateTime focusedDay) {
|
|
|
|
final selectedWeekdayOffset = _selectedDay.weekday - DateTime.monday;
|
|
|
|
final nextSelectedDay = _weekStart(focusedDay).add(
|
|
|
|
Duration(days: selectedWeekdayOffset),
|
|
|
|
);
|
|
|
|
_selectDate(nextSelectedDay, focusedDay: focusedDay);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _backToToday() {
|
|
|
|
_selectDate(_lastSelectableDay);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _selectDate(DateTime selectedDay, {DateTime? focusedDay}) {
|
|
|
|
final normalizedSelectedDay = _clampSelectableDay(selectedDay);
|
|
|
|
final normalizedFocusedDay = _clampSelectableDay(
|
|
|
|
focusedDay ?? normalizedSelectedDay,
|
|
|
|
);
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
_focusedDay = normalizedFocusedDay;
|
|
|
|
});
|
|
|
|
homeController.changeDate(normalizedSelectedDay);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _syncPagerToSelectedDay(DateTime selectedDay) {
|
|
|
|
final targetPage = _pageIndexForDay(selectedDay);
|
|
|
|
if (!_pageController.hasClients) {
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
if (!mounted) return;
|
|
|
|
_syncPagerToSelectedDay(selectedDay);
|
|
|
|
});
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
final currentPage =
|
|
|
|
_pageController.page?.round() ?? _pageController.initialPage;
|
|
|
|
if (currentPage == targetPage) return;
|
|
|
|
|
|
|
|
_pageController.animateToPage(
|
|
|
|
targetPage,
|
|
|
|
duration: const Duration(milliseconds: 220),
|
|
|
|
curve: Curves.easeOut,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
DateTime _dateForPage(int page) {
|
|
|
|
return _firstSelectableDay.add(Duration(days: page));
|
|
|
|
}
|
|
|
|
|
|
|
|
int _pageIndexForDay(DateTime day) {
|
|
|
|
final dayIndex =
|
|
|
|
_clampSelectableDay(day).difference(_firstSelectableDay).inDays;
|
|
|
|
return dayIndex.clamp(0, _dayPageCount - 1).toInt();
|
|
|
|
}
|
|
|
|
|
|
|
|
DateTime _clampSelectableDay(DateTime day) {
|
|
|
|
final normalizedDay = DateUtils.dateOnly(day);
|
|
|
|
if (normalizedDay.isBefore(_firstSelectableDay)) return _firstSelectableDay;
|
|
|
|
if (normalizedDay.isAfter(_lastSelectableDay)) return _lastSelectableDay;
|
|
|
|
return normalizedDay;
|
|
|
|
}
|
|
|
|
|
|
|
|
DateTime _weekStart(DateTime day) {
|
|
|
|
final normalizedDay = DateUtils.dateOnly(day);
|
|
|
|
return normalizedDay.subtract(Duration(days: normalizedDay.weekday - 1));
|
|
|
|
}
|
|
|
|
|
|
|
|
DateTime get _selectedDay =>
|
|
|
|
_clampSelectableDay(homeController.selectedDate.value);
|
|
|
|
|
|
|
|
String get _dateTitle {
|
|
|
|
final today = _lastSelectableDay;
|
|
|
|
if (isSameDay(_selectedDay, today)) return '今日';
|
|
|
|
if (isSameDay(_selectedDay, today.subtract(const Duration(days: 1)))) {
|
|
|
|
return '昨日';
|
|
|
|
}
|
|
|
|
if (isSameDay(_selectedDay, today.add(const Duration(days: 1)))) {
|
|
|
|
return '明日';
|
|
|
|
}
|
|
|
|
return '${_selectedDay.month}月${_selectedDay.day}日';
|
|
|
|
}
|
|
|
|
|
|
|
|
bool get _isSelectedToday => isSameDay(_selectedDay, _lastSelectableDay);
|
|
|
|
|
|
|
|
bool _isAfterToday(DateTime day) {
|
|
|
|
final normalizedDay = DateUtils.dateOnly(day);
|
|
|
|
return normalizedDay.isAfter(_lastSelectableDay);
|
|
|
|
}
|
|
|
|
|
|
|
|
Color _calendarTextColor(DateTime day, {required bool selected}) {
|
|
|
|
if (selected) return Colors.white;
|
|
|
|
return context.colors.textPrimary
|
|
|
|
.withValues(alpha: _isAfterToday(day) ? 0.2 : 0.6);
|
|
|
|
}
|
|
|
|
|
|
|
|
String _weekdayText(DateTime day) {
|
|
|
|
switch (day.weekday) {
|
|
|
|
case DateTime.monday:
|
|
|
|
return '一';
|
|
|
|
case DateTime.tuesday:
|
|
|
|
return '二';
|
|
|
|
case DateTime.wednesday:
|
|
|
|
return '三';
|
|
|
|
case DateTime.thursday:
|
|
|
|
return '四';
|
|
|
|
case DateTime.friday:
|
|
|
|
return '五';
|
|
|
|
case DateTime.saturday:
|
|
|
|
return '六';
|
|
|
|
case DateTime.sunday:
|
|
|
|
default:
|
|
|
|
return '日';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _TopDateBar extends StatelessWidget {
|
|
|
|
const _TopDateBar({
|
|
|
|
required this.title,
|
|
|
|
required this.showBackToToday,
|
|
|
|
required this.onBackToToday,
|
|
|
|
});
|
|
|
|
|
|
|
|
final String title;
|
|
|
|
final bool showBackToToday;
|
|
|
|
final VoidCallback onBackToToday;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return SizedBox(
|
|
|
|
height: _TodayTabState._topBarHeight,
|
|
|
|
child: Padding(
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
|
|
child: Row(
|
|
|
|
children: [
|
|
|
|
Text(
|
|
|
|
title,
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
style: TextStyle(
|
|
|
|
color: context.colors.textPrimary,
|
|
|
|
fontSize: 24,
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
if (showBackToToday) ...[
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
GestureDetector(
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
onTap: onBackToToday,
|
|
|
|
child: SizedBox(
|
|
|
|
height: 24,
|
|
|
|
child: Row(
|
|
|
|
children: [
|
|
|
|
Image.asset(
|
|
|
|
'assets/images/common/ic_back_to_today.png',
|
|
|
|
width: 12,
|
|
|
|
height: 12,
|
|
|
|
color: context.colors.primary,
|
|
|
|
),
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
Text(
|
|
|
|
'回今天',
|
|
|
|
style: TextStyle(
|
|
|
|
color: context.colors.primary,
|
|
|
|
fontSize: 12,
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
const Spacer(),
|
|
|
|
Obx(() {
|
|
|
|
return Get.find<UserStateService>().isVip
|
|
|
|
? const SizedBox.shrink()
|
|
|
|
: Container(
|
|
|
|
height: 29,
|
|
|
|
constraints: const BoxConstraints(minWidth: 95),
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 7),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: const Color(0xFFFFDF51),
|
|
|
|
borderRadius: BorderRadius.circular(27.6),
|
|
|
|
),
|
|
|
|
child: Row(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
children: [
|
|
|
|
Image.asset(
|
|
|
|
'assets/images/common/ic_pro.png',
|
|
|
|
width: 20,
|
|
|
|
height: 20,
|
|
|
|
color: context.colors.textPrimary,
|
|
|
|
),
|
|
|
|
const SizedBox(width: 2),
|
|
|
|
Text(
|
|
|
|
'20%优惠',
|
|
|
|
style: TextStyle(
|
|
|
|
fontSize: 14,
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
color: context.colors.textPrimary,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} |
...
|
...
|
|