Commit 72cf904b7f2962e70e6a2dca0d81709e8cfa64d0

Authored by 刘宏哲
1 parent 8c7326af

feat(app): update purchase view

... ... @@ -107,4 +107,9 @@ class PlatformHostApiImpl(private val application: android.app.Application) : Pl
callback: (Result<String?>) -> Unit
) {
}
override fun jumpAppSetting(): Boolean {
return true
}
}
... ...
... ... @@ -117,13 +117,7 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
time.isBefore(date.add(const Duration(days: 1))))
ActivityBurnHeartRatePoint(time: time, bpm: bpm.toDouble()),
]..sort((a, b) => a.time.compareTo(b.time));
final sleepTimes = <DateTime>[
for (final item in data.sleepTimeList ?? const <SleepTimeList>[])
if (_parseDateTime(item.fromTime, fallbackDate: date) case final from?)
from,
for (final item in data.sleepTimeList ?? const <SleepTimeList>[])
if (_parseDateTime(item.toTime, fallbackDate: date) case final to?) to,
]..sort();
final sleepRange = _dailySleepRange(date, data.sleepTimeList);
return ActivityBurnReport(
date: date,
... ... @@ -140,13 +134,47 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
heartRate: ActivityBurnHeartRateSummary(
startTime: date,
endTime: points.isEmpty ? null : points.last.time,
sleepStartTime: sleepTimes.isEmpty ? null : sleepTimes.first,
sleepEndTime: sleepTimes.isEmpty ? null : sleepTimes.last,
sleepStartTime: sleepRange?.start,
sleepEndTime: sleepRange?.end,
points: points,
),
);
}
_SleepRange? _dailySleepRange(
DateTime date,
List<SleepTimeList>? sleepTimeList,
) {
final dayStart = _normalizeDate(date);
final dayEnd = dayStart.add(const Duration(days: 1));
DateTime? earliestStart;
DateTime? latestEndInDay;
for (final item in sleepTimeList ?? const <SleepTimeList>[]) {
final from = _parseDateTime(item.fromTime, fallbackDate: dayStart);
final to = _parseDateTime(item.toTime, fallbackDate: dayStart);
if (from == null || to == null || !to.isAfter(from)) continue;
if (!from.isBefore(dayEnd) || !to.isAfter(dayStart)) continue;
final visibleStart = from.isBefore(dayStart) ? dayStart : from;
if (earliestStart == null || visibleStart.isBefore(earliestStart)) {
earliestStart = visibleStart;
}
final visibleEnd = to.isAfter(dayEnd) ? dayEnd : to;
if (!visibleEnd.isBefore(dayStart) && !visibleEnd.isAfter(dayEnd)) {
if (latestEndInDay == null || visibleEnd.isAfter(latestEndInDay)) {
latestEndInDay = visibleEnd;
}
}
}
final start = earliestStart;
final end = latestEndInDay;
if (start == null || end == null || !end.isAfter(start)) return null;
return _SleepRange(start, end);
}
Value? _dailyOverallValues(
DateTime date,
List<OverallList>? overallList,
... ... @@ -287,6 +315,13 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
date.year * 10000 + date.month * 100 + date.day;
}
class _SleepRange {
const _SleepRange(this.start, this.end);
final DateTime start;
final DateTime end;
}
class MockActivityBurnReportDataSource implements ActivityBurnReportDataSource {
const MockActivityBurnReportDataSource();
... ...
... ... @@ -49,109 +49,47 @@ class FriendsTab extends GetView<FriendsController> {
final stressValue = stressScore?.state == 0
? null
: stressScore?.comprehensiveScore?.toDouble();
return Stack(
children: [
RefreshIndicator(
onRefresh: controller.refreshData,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.fromLTRB(
0,
0,
0,
hasFriends ? 188.dp : 172,
final selfHealthCard = _SelfHealthCard(
name: currentUser?.nickname?.trim().isNotEmpty == true
? currentUser!.nickname!.trim()
: '-',
avatarUrl: currentUser?.avatar,
updatedAt: updatedAt == null
? context.l10n.friendsWaitingForData
: context.l10n.friendsUpdatedAt(
DateFormat('HH:mm').format(updatedAt),
),
children: [
_SelfHealthCard(
name:
currentUser?.nickname?.trim().isNotEmpty ==
true
? currentUser!.nickname!.trim()
: '-',
avatarUrl: currentUser?.avatar,
updatedAt: updatedAt == null
? context.l10n.friendsWaitingForData
: context.l10n.friendsUpdatedAt(
DateFormat('HH:mm').format(updatedAt),
),
sleepQualityScore:
_normalizedScore(healthData?.sleepScore),
steps: healthData?.steps == null
? null
: context.l10n.friendsStepCount(
healthData!.steps!,
),
statusText: stressValue == null
? context.l10n.friendsWaitingForData
: HrvStressLevel.fromRealtimeStress(
stressValue,
).label,
stressValue: stressValue,
isLoading: isSelfInitialLoading,
),
if (isFriendsInitialLoading)
const _FriendsLoadingIndicator()
else if (!hasFriends)
const _EmptyFriendsView()
else ...[
const SizedBox(height: 12),
...friends.map(
(friend) => Padding(
padding: const EdgeInsets.fromLTRB(
16,
0,
16,
12,
),
child: FriendHealthCard(
name: friend.name,
remark: friend.remark,
avatarUrl: friend.avatarUrl,
updatedAt: friend.updatedAt,
sleepQualityScore:
friend.sleepQualityScore,
steps: friend.steps,
statusText: friend.statusText,
stressValue: friend.stressValue,
isOnWatchFace: friend.isOnWatchFace,
onTap: () => _openFriendHome(friend),
onMoreSelected: (action) {
_handleFriendAction(action, friend);
},
),
),
),
],
if (!hasFriends && !isFriendsInitialLoading) ...[
const SizedBox(height: 8),
_AddFriendButton(
enabled: !isFull,
onTap: isFull ? null : _handleAddFriend,
),
if (isFull) const _FullFriendsTip(),
],
],
),
),
if (hasFriends)
Positioned(
left: 0,
right: 0,
bottom: _floatingAddButtonBottom(context),
child: Column(
children: [
_AddFriendButton(
enabled: !isFull,
friendCount: friends.length,
maxFriends: FriendsController.maxFriends,
onTap: isFull ? null : _handleAddFriend,
),
if (isFull) const _FullFriendsTip(),
],
sleepQualityScore:
_normalizedScore(healthData?.sleepScore),
steps: healthData?.steps == null
? null
: context.l10n.friendsStepCount(
healthData!.steps!,
),
),
],
statusText: stressValue == null
? context.l10n.friendsWaitingForData
: HrvStressLevel.fromRealtimeStress(
stressValue,
).label,
stressValue: stressValue,
isLoading: isSelfInitialLoading,
);
if (isFriendsInitialLoading) {
return _buildLoadingFriendsView(context, selfHealthCard);
}
if (!hasFriends) {
return _buildEmptyFriendsView(
context,
selfHealthCard,
isFull: isFull,
);
}
return _buildFriendsListView(
context,
selfHealthCard,
friends: friends,
isFull: isFull,
);
},
),
... ... @@ -163,13 +101,127 @@ class FriendsTab extends GetView<FriendsController> {
);
}
Widget _buildLoadingFriendsView(
BuildContext context,
Widget selfHealthCard,
) {
return RefreshIndicator(
onRefresh: controller.refreshData,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.fromLTRB(
0,
0,
0,
_emptyListBottomPadding(context),
),
children: [
selfHealthCard,
const _FriendsLoadingIndicator(),
],
),
);
}
Widget _buildEmptyFriendsView(
BuildContext context,
Widget selfHealthCard, {
required bool isFull,
}) {
return RefreshIndicator(
onRefresh: controller.refreshData,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.fromLTRB(
0,
0,
0,
_emptyListBottomPadding(context),
),
children: [
selfHealthCard,
_EmptyFriendsView(
enabled: !isFull,
onTap: isFull ? null : _handleAddFriend,
),
],
),
);
}
Widget _buildFriendsListView(
BuildContext context,
Widget selfHealthCard, {
required List<FriendHealthData> friends,
required bool isFull,
}) {
return Stack(
children: [
RefreshIndicator(
onRefresh: controller.refreshData,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.fromLTRB(0, 0, 0, 188.dp),
children: [
selfHealthCard,
const SizedBox(height: 12),
...friends.map(
(friend) => Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: FriendHealthCard(
name: friend.name,
remark: friend.remark,
avatarUrl: friend.avatarUrl,
updatedAt: friend.updatedAt,
sleepQualityScore: friend.sleepQualityScore,
steps: friend.steps,
statusText: friend.statusText,
stressValue: friend.stressValue,
isOnWatchFace: friend.isOnWatchFace,
onTap: () => _openFriendHome(friend),
onMoreSelected: (action) {
_handleFriendAction(action, friend);
},
),
),
),
],
),
),
Positioned(
left: 0,
right: 0,
bottom: _floatingAddButtonBottom(context),
child: Column(
children: [
_AddFriendButton(
enabled: !isFull,
friendCount: friends.length,
maxFriends: FriendsController.maxFriends,
onTap: isFull ? null : _handleAddFriend,
),
if (isFull) const _FullFriendsTip(),
],
),
),
],
);
}
double _emptyListBottomPadding(BuildContext context) {
return _tabBarAvoidanceBottom(context) + 24;
}
double _floatingAddButtonBottom(BuildContext context) {
return _tabBarAvoidanceBottom(context) + 23.dp;
}
double _tabBarAvoidanceBottom(BuildContext context) {
const tabBarHeight = 56.0;
const tabBarBottomPadding = 8.0;
return MediaQuery.viewPaddingOf(context).bottom +
tabBarHeight +
tabBarBottomPadding +
23.dp;
tabBarBottomPadding;
}
Future<void> _handleAddFriend() async {
... ... @@ -324,7 +376,13 @@ _navigatorToPrivacySettings() {
}
class _EmptyFriendsView extends StatelessWidget {
const _EmptyFriendsView();
const _EmptyFriendsView({
required this.enabled,
this.onTap,
});
final bool enabled;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
... ... @@ -351,6 +409,12 @@ class _EmptyFriendsView extends StatelessWidget {
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: 12),
_AddFriendButton(
enabled: enabled,
onTap: onTap,
),
if (!enabled) const _FullFriendsTip(),
],
),
);
... ...
... ... @@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../routes/app_pages.dart';
import '../../purchase/purchase_route_args.dart';
import '../../app_review_prompt/logic/app_review_prompt_logic.dart';
import '../../report_common/models/report_period.dart';
import 'trend/trend_controller.dart';
... ... @@ -39,7 +40,10 @@ class HomeController extends GetxController {
date: today,
userId: userId,
);
await Get.toNamed(Routes.PURCHASE);
await Get.toNamed(
Routes.PURCHASE,
arguments: {PurchaseRouteArgs.showCloseButton: true},
);
return true;
}
... ...
... ... @@ -42,9 +42,10 @@ class HrvReportLogic extends ReportPeriodLogic {
(nextPeriod == ReportPeriod.week || nextPeriod == ReportPeriod.month)) {
final now = DateTime.now();
final selectedYear = selectedDate.value.year;
selectedDate.value = selectedYear == now.year
final date = selectedYear == now.year
? DateTime(now.year, now.month, now.day)
: DateTime(selectedYear);
return selectQuery(nextPeriod, date, forceRefresh: forceRefresh);
}
return super.selectPeriod(nextPeriod, forceRefresh: forceRefresh);
}
... ...
... ... @@ -4,6 +4,7 @@ import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:lottie/lottie.dart';
import '../controllers/premium_activated_controller.dart';
... ... @@ -59,49 +60,54 @@ class PremiumActivatedView extends GetView<PremiumActivatedController> {
}
Widget _buildContentView(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
return Stack(
children: [
SizedBox(height: 150.h),
const _FloatingPremiumIcon(),
SizedBox(height: 84.h),
Text(
context.l10n.premiumActivatedTitle,
style: const TextStyle(
fontSize: 20,
color: Color(0xFF0F0F11),
fontWeight: FontWeightExt.semibold),
textAlign: TextAlign.center,
),
SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Text(context.l10n.premiumActivatedDescription,
style: const TextStyle(fontSize: 14, color: Color(0xFF0F0F11)),
textAlign: TextAlign.center),
),
Expanded(child: Container()),
GestureDetector(
onTap: () {
controller.executeContinueLogic(context);
},
child: Container(
width: 280.dp,
height: 48.dp,
decoration: BoxDecoration(
color: Color(0xFF845EEE),
borderRadius: BorderRadius.circular(24.dp),
Lottie.asset('assets/lottie/scatter_flowers.json'),
Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 150.h),
const _FloatingPremiumIcon(),
SizedBox(height: 84.h),
Text(
context.l10n.premiumActivatedTitle,
style: const TextStyle(
fontSize: 20,
color: Color(0xFF0F0F11),
fontWeight: FontWeightExt.semibold),
textAlign: TextAlign.center,
),
alignment: Alignment.center,
child: Text(context.l10n.premiumActivatedContinue,
style: const TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeightExt.bold)),
),
SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Text(context.l10n.premiumActivatedDescription,
style: const TextStyle(fontSize: 14, color: Color(0xFF0F0F11)),
textAlign: TextAlign.center),
),
Expanded(child: Container()),
GestureDetector(
onTap: () {
controller.executeContinueLogic(context);
},
child: Container(
width: 280.dp,
height: 48.dp,
decoration: BoxDecoration(
color: Color(0xFF845EEE),
borderRadius: BorderRadius.circular(24.dp),
),
alignment: Alignment.center,
child: Text(context.l10n.premiumActivatedContinue,
style: const TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeightExt.bold)),
),
),
SizedBox(height: 36),
],
),
SizedBox(height: 36),
],
);
}
... ...
abstract final class PurchaseRouteArgs {
static const showCloseButton = 'showCloseButton';
}
... ...
... ... @@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../controllers/purchase_controller.dart';
import '../purchase_route_args.dart';
import '../widgets/purchase_bottom_bar.dart';
import '../widgets/purchase_colors.dart';
import '../widgets/purchase_plan_card.dart';
... ... @@ -176,6 +177,9 @@ class _PurchaseNavigationBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final topInset = MediaQuery.paddingOf(context).top;
final arguments = Get.arguments;
final showCloseButton = arguments is Map &&
arguments[PurchaseRouteArgs.showCloseButton] == true;
return Obx(
() => AnimatedContainer(
duration: const Duration(milliseconds: 160),
... ... @@ -188,7 +192,9 @@ class _PurchaseNavigationBar extends StatelessWidget {
IconButton(
onPressed: Get.back,
icon: Image.asset(
'assets/images/common/ic_nav_back.webp',
showCloseButton
? R.assetsImagesCloseIcon
: R.assetsImagesNavBackIcon,
width: 24,
height: 24,
),
... ...
... ... @@ -16,6 +16,7 @@ abstract class ReportPeriodLogic {
final selectedPeriod = ReportPeriod.day.obs;
final selectedDate = _today().obs;
final isLoading = false.obs;
final Map<ReportPeriod, DateTime> _datesByPeriod = {};
List<ReportPeriod> get supportedPeriods => ReportPeriod.values;
... ... @@ -48,8 +49,9 @@ abstract class ReportPeriodLogic {
void initializeQuery(ReportPeriod period, DateTime date) {
final nextPeriod = normalizePeriod(period);
_seedPeriodDates(date);
selectedPeriod.value = nextPeriod;
selectedDate.value = normalizeDate(nextPeriod, date);
selectedDate.value = _datesByPeriod[nextPeriod]!;
}
Future<void> selectQuery(
... ... @@ -65,8 +67,10 @@ abstract class ReportPeriodLogic {
selectedDate.value == nextDate) {
return Future.value();
}
_datesByPeriod[selectedPeriod.value] = selectedDate.value;
selectedPeriod.value = nextPeriod;
selectedDate.value = nextDate;
_datesByPeriod[nextPeriod] = nextDate;
return loadReport();
}
... ... @@ -126,7 +130,11 @@ abstract class ReportPeriodLogic {
if (!shouldRefresh && selectedPeriod.value == nextPeriod) {
return Future.value();
}
_datesByPeriod[selectedPeriod.value] = selectedDate.value;
selectedPeriod.value = nextPeriod;
selectedDate.value = _datesByPeriod[nextPeriod] ??
normalizeDate(nextPeriod, selectedDate.value);
_datesByPeriod[nextPeriod] = selectedDate.value;
return loadReport();
}
... ... @@ -138,6 +146,7 @@ abstract class ReportPeriodLogic {
final nextDate = ReportDateRangeConfig.clampDate(date);
if (!shouldRefresh && selectedDate.value == nextDate) return Future.value();
selectedDate.value = nextDate;
_datesByPeriod[selectedPeriod.value] = nextDate;
return loadReport();
}
... ... @@ -153,6 +162,7 @@ abstract class ReportPeriodLogic {
);
if (!shouldRefresh && weekStart == nextWeekStart) return Future.value();
selectedDate.value = nextWeekStart;
_datesByPeriod[selectedPeriod.value] = nextWeekStart;
return loadReport();
}
... ... @@ -169,6 +179,7 @@ abstract class ReportPeriodLogic {
);
if (!shouldRefresh && monthStart == nextMonth) return Future.value();
selectedDate.value = nextMonth;
_datesByPeriod[selectedPeriod.value] = nextMonth;
return loadReport();
}
... ... @@ -185,6 +196,7 @@ abstract class ReportPeriodLogic {
return Future.value();
}
selectedDate.value = DateTime(nextYear);
_datesByPeriod[selectedPeriod.value] = selectedDate.value;
return loadReport();
}
... ... @@ -212,6 +224,7 @@ abstract class ReportPeriodLogic {
final candidate = _previousCandidate;
if (_isBeforeRange(candidate)) return Future.value();
selectedDate.value = candidate;
_datesByPeriod[selectedPeriod.value] = candidate;
return loadReport();
}
... ... @@ -219,6 +232,7 @@ abstract class ReportPeriodLogic {
final candidate = _nextCandidate;
if (_isAfterRange(candidate)) return Future.value();
selectedDate.value = candidate;
_datesByPeriod[selectedPeriod.value] = candidate;
return loadReport();
}
... ... @@ -226,6 +240,12 @@ abstract class ReportPeriodLogic {
void dispose() {}
void _seedPeriodDates(DateTime date) {
for (final period in supportedPeriods) {
_datesByPeriod[period] = normalizeDate(period, date);
}
}
DateTime get _previousCandidate => switch (selectedPeriod.value) {
ReportPeriod.year => DateTime(selectedDate.value.year - 1),
ReportPeriod.month =>
... ...
... ... @@ -1205,13 +1205,6 @@ class _ChartPlotFrame extends StatelessWidget {
),
),
),
Padding(
padding: const EdgeInsets.only(
left: horizontalInset,
right: rightAxisWidth,
),
child: child,
),
Positioned.fill(
bottom: bottomTitleHeight,
child: CustomPaint(
... ... @@ -1222,6 +1215,13 @@ class _ChartPlotFrame extends StatelessWidget {
),
),
),
Padding(
padding: const EdgeInsets.only(
left: horizontalInset,
right: rightAxisWidth,
),
child: child,
),
Positioned.fill(
bottom: bottomTitleHeight,
child: _RightAxisLabels(
... ...
class R {
static final String assetsImagesNavBackIcon =
'assets/images/common/ic_nav_back.webp';
static final String assetsImagesCloseIcon =
'assets/images/common/ic_close.png';
static final String assetsImagesHealthGoodArrow =
'assets/images/common/ic_health_good_arrow.webp';
static final String assetsImagesHealthBadArrow =
... ...