Commit 8dddb91537baea53e6ae15cd30957d0d5a10772a

Authored by 刘宏哲
1 parent 87e39b96

feat(app): update purchase view

Showing 72 changed files with 1828 additions and 743 deletions
... ... @@ -7,8 +7,9 @@ import android.webkit.WebSettings
import com.doublefeel.app.pigeon.platform.AppleProductInfo
import com.doublefeel.app.pigeon.platform.AppleProductPaymentResult
import com.doublefeel.app.pigeon.platform.AppleSignInModel
import com.doublefeel.app.pigeon.platform.HResourceType
import com.doublefeel.app.pigeon.platform.PlatformHostApi
import com.doublefeel.app.pigeon.platform.WatchAppOtherInfo
import io.flutter.BuildConfig
import kotlin.math.max
import kotlin.math.min
... ... @@ -74,8 +75,6 @@ class PlatformHostApiImpl(private val application: android.app.Application) : Pl
) {
}
override fun performApplePayment(
productId: String,
uuid: String,
... ... @@ -86,11 +85,26 @@ class PlatformHostApiImpl(private val application: android.app.Application) : Pl
override fun performRestore(callback: (Result<Boolean>) -> Unit) {
}
override fun updateWatchOtherUserInfo(otherInfo: WatchAppOtherInfo?) {
override fun logout() {
}
override fun refreshVip() {
}
override fun refreshWatchSurface() {
override fun refreshWatchAppAndWidgets() {
}
override fun isDebugEnvoriment(): Boolean {
return BuildConfig.DEBUG
}
override fun requestAppReview(callback: (Result<Boolean>) -> Unit) {
}
override fun uploadFile(
filePath: String,
resourceType: HResourceType,
callback: (Result<String?>) -> Unit
) {
}
}
... ...
... ... @@ -54,4 +54,7 @@ class WearEngineHostApiImpl(
override fun removeBackground(originImagePath: String, callback: (Result<String?>) -> Unit) {
}
override fun hasInstalledWatchSurface(callback: (Result<Boolean>) -> Unit) {
}
}
... ...
No preview for this file type
No preview for this file type
No preview for this file type
... ... @@ -64,10 +64,11 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
weekEnd: start.add(const Duration(days: 6)),
days: _periodReports(start, 7, data),
totalActiveEnergyOverride: data.totalMove?.round(),
totalExerciseMinutesOverride: data.totalExercise?.round(),
totalStandHoursOverride: data.totalStand?.round(),
totalExerciseMinutesOverride: _durationMinutes(data.totalExercise),
totalStandHoursOverride: _durationMinutes(data.totalStand),
averageDailyActiveEnergyOverride: data.avgMove?.round(),
activeEnergyGoalOverride: data.activityTargetInfo?.move?.round(),
previousAverageDailyActiveEnergyOverride: data.qoqAvgMove?.round(),
),
AppFailure() => WeeklyActivityBurnReport.empty(start),
};
... ... @@ -91,10 +92,11 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
monthEnd: end,
days: _periodReports(start, end.day, data),
totalActiveEnergyOverride: data.totalMove?.round(),
totalExerciseMinutesOverride: data.totalExercise?.round(),
totalStandHoursOverride: data.totalStand?.round(),
totalExerciseMinutesOverride: _durationMinutes(data.totalExercise),
totalStandHoursOverride: _durationMinutes(data.totalStand),
averageDailyActiveEnergyOverride: data.avgMove?.round(),
activeEnergyGoalOverride: data.activityTargetInfo?.move?.round(),
previousAverageDailyActiveEnergyOverride: data.qoqAvgMove?.round(),
),
AppFailure() => MonthlyActivityBurnReport.empty(start),
};
... ... @@ -127,11 +129,11 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
date: date,
activeEnergy:
_metric(overallValues?.move ?? data.totalMove, target?.move),
exerciseMinutes: _metric(
exerciseMinutes: _exerciseDurationMetric(
overallValues?.exercise ?? data.totalExercise,
target?.exercise,
),
standHours: _metric(
standHours: _standDurationMetric(
overallValues?.stand ?? data.totalStand,
target?.stand,
),
... ... @@ -169,11 +171,22 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
if (_parseDateTime(item.timeKey) case final time?)
if (item.value case final value?) _normalizeDate(time): value,
};
final moveByDate = <DateTime, num>{
for (final item in data.moveTrendList ?? const <MoveTrendList>[])
if (_parseDateTime(item.timeKey) case final time?)
if (item.value case final value?) _normalizeDate(time): value,
};
final moveByDate = <DateTime, num>{};
final moveTrendItems = data.moveTrendList ?? const <MoveTrendList>[];
for (var i = 0; i < moveTrendItems.length && i < dayCount; i++) {
final item = moveTrendItems[i];
final value = item.value;
if (value == null) continue;
final parsedTime = _parseDateTime(item.timeKey);
final parsedDate = parsedTime == null ? null : _normalizeDate(parsedTime);
final fallbackDate = start.add(Duration(days: i));
final date = parsedDate != null &&
!parsedDate.isBefore(start) &&
parsedDate.isBefore(start.add(Duration(days: dayCount)))
? parsedDate
: fallbackDate;
moveByDate[date] = value;
}
return [
for (var i = 0; i < dayCount; i++)
... ... @@ -198,8 +211,9 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
return ActivityBurnReport(
date: date,
activeEnergy: _metric(move, target?.move),
exerciseMinutes: _metric(values?.exercise, target?.exercise),
standHours: _metric(values?.stand, target?.stand),
exerciseMinutes:
_exerciseDurationMetric(values?.exercise, target?.exercise),
standHours: _standDurationMetric(values?.stand, target?.stand),
);
}
... ... @@ -208,6 +222,40 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
return ActivityBurnMetric(value: value.round(), goal: goal?.round() ?? 0);
}
ActivityBurnMetric? _exerciseDurationMetric(num? seconds, num? target) {
if (seconds == null) return null;
return ActivityBurnMetric(
value: _secondsToWholeMinutes(seconds),
goal: _exerciseTargetMinutes(target),
);
}
ActivityBurnMetric? _standDurationMetric(num? seconds, num? target) {
if (seconds == null) return null;
return ActivityBurnMetric(
value: _secondsToWholeMinutes(seconds),
goal: _standTargetMinutes(target),
);
}
int? _durationMinutes(num? seconds) =>
seconds == null ? null : _secondsToWholeMinutes(seconds);
int _secondsToWholeMinutes(num? seconds) {
if (seconds == null || seconds < 60) return 0;
return seconds ~/ 60;
}
int _exerciseTargetMinutes(num? target) {
if (target == null || target <= 0) return 0;
return target >= 60 ? _secondsToWholeMinutes(target) : target.round();
}
int _standTargetMinutes(num? target) {
if (target == null || target <= 0) return 0;
return target >= 60 ? _secondsToWholeMinutes(target) : target.round() * 60;
}
DateTime? _parseDateTime(Object? value, {DateTime? fallbackDate}) {
if (value is num) {
if (value >= 1000000000) {
... ...
... ... @@ -79,6 +79,7 @@ class WeeklyActivityBurnReport {
this.totalStandHoursOverride,
this.averageDailyActiveEnergyOverride,
this.activeEnergyGoalOverride,
this.previousAverageDailyActiveEnergyOverride,
});
final DateTime weekStart;
... ... @@ -89,6 +90,7 @@ class WeeklyActivityBurnReport {
final int? totalStandHoursOverride;
final int? averageDailyActiveEnergyOverride;
final int? activeEnergyGoalOverride;
final int? previousAverageDailyActiveEnergyOverride;
List<ActivityBurnReport> get daysWithData =>
days.where((report) => report.hasData).toList();
... ... @@ -97,7 +99,8 @@ class WeeklyActivityBurnReport {
daysWithData.isNotEmpty ||
totalActiveEnergyOverride != null ||
totalExerciseMinutesOverride != null ||
totalStandHoursOverride != null;
totalStandHoursOverride != null ||
activeEnergyGoalOverride != null;
int get totalActiveEnergy =>
totalActiveEnergyOverride ??
... ... @@ -132,6 +135,15 @@ class WeeklyActivityBurnReport {
.map((report) => report.activeEnergy?.goal ?? 0)
.firstWhere((goal) => goal > 0, orElse: () => 0);
int? get previousAverageDailyActiveEnergy =>
previousAverageDailyActiveEnergyOverride;
int? get activeEnergyComparisonPercent {
final previous = previousAverageDailyActiveEnergy;
if (previous == null || previous <= 0) return null;
return (((averageDailyActiveEnergy - previous) / previous) * 100).round();
}
int get perfectRingDays => daysWithData
.where((report) =>
(report.activeEnergy?.progress ?? 0) >= 1 &&
... ... @@ -173,6 +185,7 @@ class MonthlyActivityBurnReport {
this.totalStandHoursOverride,
this.averageDailyActiveEnergyOverride,
this.activeEnergyGoalOverride,
this.previousAverageDailyActiveEnergyOverride,
});
final DateTime monthStart;
... ... @@ -183,6 +196,7 @@ class MonthlyActivityBurnReport {
final int? totalStandHoursOverride;
final int? averageDailyActiveEnergyOverride;
final int? activeEnergyGoalOverride;
final int? previousAverageDailyActiveEnergyOverride;
List<ActivityBurnReport> get daysWithData =>
days.where((report) => report.hasData).toList();
... ... @@ -191,7 +205,8 @@ class MonthlyActivityBurnReport {
daysWithData.isNotEmpty ||
totalActiveEnergyOverride != null ||
totalExerciseMinutesOverride != null ||
totalStandHoursOverride != null;
totalStandHoursOverride != null ||
activeEnergyGoalOverride != null;
int get totalActiveEnergy =>
totalActiveEnergyOverride ??
... ... @@ -226,6 +241,15 @@ class MonthlyActivityBurnReport {
.map((report) => report.activeEnergy?.goal ?? 0)
.firstWhere((goal) => goal > 0, orElse: () => 0);
int? get previousAverageDailyActiveEnergy =>
previousAverageDailyActiveEnergyOverride;
int? get activeEnergyComparisonPercent {
final previous = previousAverageDailyActiveEnergy;
if (previous == null || previous <= 0) return null;
return (((averageDailyActiveEnergy - previous) / previous) * 100).round();
}
int get perfectRingDays => daysWithData
.where((report) =>
(report.activeEnergy?.progress ?? 0) >= 1 &&
... ...
... ... @@ -113,11 +113,30 @@ class _DailyReportContent extends StatelessWidget {
ActivityBurnSummaryCard(report: report),
const SizedBox(height: 12),
ActivityBurnHeartRateZoneCard(
summary: report?.heartRate ?? const ActivityBurnHeartRateSummary(),
summary: _heartRateSummary,
),
],
);
}
ActivityBurnHeartRateSummary get _heartRateSummary {
final dailyReport = report;
if (dailyReport == null) return const ActivityBurnHeartRateSummary();
final summary = dailyReport.heartRate;
if (summary.startTime != null || summary.points.isNotEmpty) {
return summary;
}
return ActivityBurnHeartRateSummary(
startTime: dailyReport.date,
endTime: dailyReport.date,
sleepStartTime: summary.sleepStartTime,
sleepEndTime: summary.sleepEndTime,
userAge: summary.userAge,
points: summary.points,
);
}
}
class _DateSwitcher extends StatelessWidget {
... ...
import 'package:flutter/widgets.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
class ActivityBurnDurationText {
const ActivityBurnDurationText({
required this.value,
required this.unit,
});
final String value;
final String unit;
}
ActivityBurnDurationText activityBurnDurationText(
BuildContext context,
int? minutes,
) {
if (minutes == null) {
return ActivityBurnDurationText(
value: '-',
unit: context.l10n.reportUnitMinute,
);
}
if (minutes >= 60) {
return ActivityBurnDurationText(
value: (minutes ~/ 60).toString(),
unit: context.l10n.reportUnitHour,
);
}
return ActivityBurnDurationText(
value: minutes.toString(),
unit: context.l10n.reportUnitMinute,
);
}
... ...
... ... @@ -67,6 +67,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
fit: StackFit.expand,
alignment: Alignment.center,
children: [
Positioned(
... ... @@ -99,12 +100,20 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
),
),
if (!summary.hasData)
Text(
context.l10n.reportNoDataToday,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
fontWeight: FontWeight.w400,
Positioned(
left: 0,
right: 6,
top: 0,
bottom: 20,
child: Center(
child: Text(
context.l10n.reportNoDataToday,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
),
),
],
... ... @@ -152,11 +161,11 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: points.isNotEmpty,
showTitles: true,
reservedSize: 28,
interval: 1,
getTitlesWidget: (value, meta) {
if (!axis.shouldShowYLabel(value)) {
if (points.isEmpty || !axis.shouldShowYLabel(value)) {
return const SizedBox.shrink();
}
return Transform.translate(
... ... @@ -202,7 +211,7 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
),
),
lineBarsData: points.isEmpty
? []
? [_emptyPlaceholderBar(axis)]
: [
if (sleepRange != null)
_sleepRangeBar(sleepRange, axis.maxY, axis.minY),
... ... @@ -211,6 +220,19 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
);
}
LineChartBarData _emptyPlaceholderBar(_HeartRateAxis axis) {
return LineChartBarData(
spots: [
FlSpot(0, axis.minY),
FlSpot(axis.maxX, axis.minY),
],
color: Colors.transparent,
barWidth: 0,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(show: false),
);
}
LineChartBarData _sleepRangeBar(
_SleepRange range,
double maxY,
... ...
import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import '../../../../r.dart';
import '../../report_common/widgets/chart_selection_line_overlay.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../../report_common/utils/report_localization.dart';
import '../../report_common/widgets/report_month_calendar_grid.dart';
import '../models/activity_burn_report_models.dart';
import 'activity_burn_duration_text.dart';
import 'activity_burn_ring.dart';
import 'activity_burn_trend_lines.dart';
... ... @@ -50,6 +54,14 @@ class _MonthlySummary extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasData = report.hasData;
final exercise = activityBurnDurationText(
context,
hasData ? report.totalExerciseMinutes : null,
);
final stand = activityBurnDurationText(
context,
hasData ? report.totalStandHours : null,
);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 2),
child: Column(
... ... @@ -107,16 +119,16 @@ class _MonthlySummary extends StatelessWidget {
_SummaryMetric(
title: HealthReportSubjectScope.titleOf(
context, context.l10n.activityExerciseTotalDuration),
value: hasData ? report.totalExerciseMinutes.toString() : '-',
unit: context.l10n.reportUnitMinute,
value: exercise.value,
unit: exercise.unit,
color: ActivityBurnMonthReportView._exercise,
),
const SizedBox(width: 34),
_SummaryMetric(
title: HealthReportSubjectScope.titleOf(
context, context.l10n.activityStandTotalDuration),
value: hasData ? report.totalStandHours.toString() : '-',
unit: context.l10n.reportUnitHour,
value: stand.value,
unit: stand.unit,
color: ActivityBurnMonthReportView._stand,
),
],
... ... @@ -401,6 +413,9 @@ class _MonthEnergyTrendCard extends StatefulWidget {
}
class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
static const _chartTopInset = 8.0;
static const _barColor = Color(0xFFFF5279);
int? _touchedIndex;
Offset? _touchedOffset;
... ... @@ -408,6 +423,8 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
Widget build(BuildContext context) {
final hasData = widget.report.hasData;
final maxY = _maxY;
final comparisonPercent = widget.report.activeEnergyComparisonPercent;
final hasChartReference = _hasChartReference;
return Container(
height: 344,
padding: const EdgeInsets.fromLTRB(20, 20, 14, 40),
... ... @@ -427,7 +444,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 20),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
... ... @@ -454,10 +471,18 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
const SizedBox(height: 2),
Row(
children: [
if (comparisonPercent != null) ...[
Image.asset(
comparisonPercent >= 0
? R.assetsImagesHealthTrendUp
: R.assetsImagesHealthTrendDown,
width: 12,
height: 12,
),
const SizedBox(width: 2),
],
Text(
hasData
? context.l10n.activityComparedLastMonth
: context.l10n.activityComparedUnavailable,
_comparisonText(comparisonPercent),
style: TextStyle(
color: ActivityBurnMonthReportView._h2,
fontSize: 12,
... ... @@ -483,40 +508,45 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
children: [
Positioned(
left: 0,
right: 6,
top: 0,
bottom: 32,
child: ActivityBurnTrendLines(
right: 0,
top: _chartTopInset,
bottom: 0,
child: ActivityBurnTrendPlotFrame(
maxY: maxY,
average: widget.report.averageDailyActiveEnergy.toDouble(),
target: widget.report.activeEnergyGoal.toDouble(),
),
),
BarChart(_chartData(maxY)),
const Positioned(
left: 0,
right: 6,
bottom: 24,
child: SizedBox(
height: 1,
child: ActivityBurnAxisDashedLine(),
child: LayoutBuilder(
builder: (context, constraints) => BarChart(
_chartData(maxY, constraints.maxWidth),
),
),
),
),
if (_touchedOffset != null)
Positioned.fill(
top: _chartTopInset,
child: ChartSelectionLineOverlay(
offset: _touchedOffset!,
color: ActivityBurnMonthReportView._h3,
bottomTitleHeight: 20,
bottomTitleHeight:
ActivityBurnTrendPlotFrame.bottomTitleHeight,
tooltipMargin: 6,
plotLeft: ActivityBurnTrendPlotFrame.horizontalInset,
plotRight: ActivityBurnTrendPlotFrame.rightAxisWidth,
),
),
if (!hasData)
Text(
context.l10n.reportWaitingForData,
style: const TextStyle(
color: ActivityBurnMonthReportView._h3,
fontSize: 12,
if (!hasChartReference)
Positioned.fill(
top: _chartTopInset,
bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight,
child: Center(
child: Text(
context.l10n.reportWaitingForData,
style: const TextStyle(
color: ActivityBurnMonthReportView._h3,
fontSize: 12,
),
),
),
),
],
... ... @@ -531,19 +561,42 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
final maxValue = widget.report.days
.map((day) => day.activeEnergy?.value ?? 0)
.fold<int>(0, (max, value) => value > max ? value : max);
final referenceMax = [
maxValue,
widget.report.averageDailyActiveEnergy,
widget.report.activeEnergyGoal,
].reduce((max, value) => value > max ? value : max);
return ((referenceMax / 100).ceil().clamp(4, 9) * 100).toDouble();
final maxReference = math.max(maxValue, widget.report.activeEnergyGoal);
return _roundUpToFiveOrZero(maxReference)
.clamp(100, double.infinity)
.toDouble();
}
bool get _hasChartReference =>
widget.report.days.any((day) => (day.activeEnergy?.value ?? 0) > 0) ||
widget.report.activeEnergyGoal > 0;
String _comparisonText(int? percent) {
if (percent == null || !widget.report.hasData) {
return '比上月-';
}
if (percent == 0) return '与上月持平';
final direction = percent > 0 ? '多' : '少';
return '比上月$direction${percent.abs()}%';
}
BarChartData _chartData(double maxY) {
double _barToY(int value, double maxY) {
if (value <= 0) return 0;
return math.max(value.toDouble(), maxY * 0.012);
}
double _roundUpToFiveOrZero(int value) {
if (value <= 0) return 100;
return (value / 5).ceil() * 5.0;
}
BarChartData _chartData(double maxY, double plotWidth) {
final hasData = widget.report.hasData;
return BarChartData(
minY: 0,
maxY: maxY,
alignment: BarChartAlignment.spaceBetween,
groupsSpace: _barGroupsSpace(plotWidth, widget.report.days.length, 4),
gridData: FlGridData(
show: false,
),
... ... @@ -606,10 +659,13 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
showTitles: false,
reservedSize: 36,
interval: 100,
getTitlesWidget: (value, meta) {
final isHundredTick = value.round() % 100 == 0;
final isMax = (value - maxY).abs() < 0.001;
if (!isHundredTick && !isMax) return const SizedBox.shrink();
return Transform.translate(
offset: const Offset(0, -8),
child: Padding(
... ... @@ -629,7 +685,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 32,
reservedSize: ActivityBurnTrendPlotFrame.bottomTitleHeight,
interval: 5,
getTitlesWidget: (value, meta) {
final day = value.toInt() + 1;
... ... @@ -639,12 +695,13 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 14),
padding: const EdgeInsets.only(top: 4),
child: Text(
day.toString(),
style: const TextStyle(
color: ActivityBurnMonthReportView._h3,
fontSize: 10,
height: 1.1,
),
),
);
... ... @@ -660,12 +717,14 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
barRods: [
BarChartRodData(
fromY: 0,
toY:
(widget.report.days[i].activeEnergy?.value ?? 0).toDouble(),
toY: _barToY(
widget.report.days[i].activeEnergy?.value ?? 0,
maxY,
),
width: 4,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(4)),
color: ActivityBurnMonthReportView._active,
color: _barColor,
),
],
),
... ... @@ -681,6 +740,12 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
}
}
double _barGroupsSpace(double plotWidth, int count, double barWidth) {
if (count <= 1) return 0;
final space = (plotWidth - barWidth * count) / (count - 1);
return space < 0 ? 0 : space;
}
class _TrendLegend extends StatelessWidget {
const _TrendLegend({
required this.color,
... ...
... ... @@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import '../models/activity_burn_report_models.dart';
import 'activity_burn_duration_text.dart';
class ActivityBurnSummaryCard extends StatelessWidget {
const ActivityBurnSummaryCard({
... ... @@ -17,6 +18,14 @@ class ActivityBurnSummaryCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final exercise = activityBurnDurationText(
context,
report?.exerciseMinutes?.value,
);
final stand = activityBurnDurationText(
context,
report?.standHours?.value,
);
return Container(
height: 90,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
... ... @@ -30,7 +39,7 @@ class ActivityBurnSummaryCard extends StatelessWidget {
child: _MetricColumn(
title: context.l10n.activityMove,
color: _active,
value: report?.activeEnergy?.value,
value: report?.activeEnergy?.value.toString() ?? '-',
unit: context.l10n.reportUnitKcal,
),
),
... ... @@ -38,16 +47,16 @@ class ActivityBurnSummaryCard extends StatelessWidget {
child: _MetricColumn(
title: context.l10n.activityExercise,
color: _exercise,
value: report?.exerciseMinutes?.value,
unit: context.l10n.reportUnitMinute,
value: exercise.value,
unit: exercise.unit,
),
),
Expanded(
child: _MetricColumn(
title: context.l10n.activityStand,
color: _stand,
value: report?.standHours?.value,
unit: context.l10n.reportUnitHour,
value: stand.value,
unit: stand.unit,
),
),
],
... ... @@ -66,7 +75,7 @@ class _MetricColumn extends StatelessWidget {
final String title;
final Color color;
final int? value;
final String value;
final String unit;
@override
... ... @@ -88,7 +97,7 @@ class _MetricColumn extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
value?.toString() ?? '-',
value,
style: const TextStyle(
color: Color(0xFF0F0F11),
fontSize: 26,
... ...
... ... @@ -37,6 +37,69 @@ class ActivityBurnAxisDashedLine extends StatelessWidget {
}
}
class ActivityBurnTrendPlotFrame extends StatelessWidget {
const ActivityBurnTrendPlotFrame({
super.key,
required this.maxY,
required this.average,
required this.target,
required this.child,
});
static const horizontalInset = 20.0;
static const rightAxisWidth = 32.0;
static const bottomTitleHeight = 28.0;
final double maxY;
final double average;
final double target;
final Widget child;
@override
Widget build(BuildContext context) {
final ticks = _ticks;
return Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
bottom: bottomTitleHeight,
child: CustomPaint(
painter: _ActivityBurnTrendFramePainter(
maxY: maxY,
ticks: ticks,
average: average,
target: target,
),
),
),
Padding(
padding: const EdgeInsets.only(
left: horizontalInset,
right: rightAxisWidth,
),
child: child,
),
Positioned.fill(
bottom: bottomTitleHeight,
child: _ActivityBurnRightAxisLabels(
maxY: maxY,
ticks: ticks,
),
),
],
);
}
List<double> get _ticks {
final values = <double>[0];
for (var value = 100.0; value < maxY; value += 100) {
values.add(value);
}
if ((values.last - maxY).abs() > 0.001) values.add(maxY);
return values;
}
}
class ActivityBurnHorizontalGridLines extends StatelessWidget {
const ActivityBurnHorizontalGridLines({
super.key,
... ... @@ -63,6 +126,141 @@ class ActivityBurnHorizontalGridLines extends StatelessWidget {
}
}
class _ActivityBurnTrendFramePainter extends CustomPainter {
const _ActivityBurnTrendFramePainter({
required this.maxY,
required this.ticks,
required this.average,
required this.target,
});
final double maxY;
final List<double> ticks;
final double average;
final double target;
static const _gridColor = Color(0xFFF3F3F3);
static const _averageColor = Color(0xFFFFC0CE);
static const _targetColor = Color(0xFF96E9CB);
@override
void paint(Canvas canvas, Size size) {
if (maxY <= 0 || size.isEmpty) return;
for (final tick in ticks) {
_drawDashedLine(
canvas,
size.width,
_yFor(tick, size.height),
color: _gridColor,
strokeWidth: 1,
);
}
if (average > 0) {
_drawSolidLine(
canvas,
size.width,
_yFor(average.clamp(0, maxY).toDouble(), size.height),
color: _averageColor,
);
}
if (target > 0) {
_drawDashedLine(
canvas,
size.width,
_yFor(target.clamp(0, maxY).toDouble(), size.height),
color: _targetColor,
strokeWidth: 1,
);
}
}
double _yFor(double value, double height) =>
height * (1 - (value / maxY).clamp(0, 1));
void _drawSolidLine(
Canvas canvas,
double width,
double y, {
required Color color,
}) {
canvas.drawLine(
Offset(0, y),
Offset(width, y),
Paint()
..color = color
..strokeWidth = 2,
);
}
void _drawDashedLine(
Canvas canvas,
double width,
double y, {
required Color color,
required double strokeWidth,
}) {
final paint = Paint()
..color = color
..strokeWidth = strokeWidth;
for (var x = 0.0; x < width; x += 4) {
canvas.drawLine(Offset(x, y), Offset((x + 2).clamp(0, width), y), paint);
}
}
@override
bool shouldRepaint(_ActivityBurnTrendFramePainter oldDelegate) =>
maxY != oldDelegate.maxY ||
ticks != oldDelegate.ticks ||
average != oldDelegate.average ||
target != oldDelegate.target;
}
class _ActivityBurnRightAxisLabels extends StatelessWidget {
const _ActivityBurnRightAxisLabels({
required this.maxY,
required this.ticks,
});
final double maxY;
final List<double> ticks;
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: LayoutBuilder(
builder: (context, constraints) {
if (maxY <= 0) return const SizedBox.shrink();
return Stack(
clipBehavior: Clip.none,
children: [
for (final tick in ticks)
Positioned(
top: constraints.maxHeight * (1 - tick / maxY) - 14,
right: 0,
width: ActivityBurnTrendPlotFrame.rightAxisWidth,
child: Align(
alignment: Alignment.centerRight,
child: Text(
tick.toInt().toString(),
maxLines: 1,
textAlign: TextAlign.right,
style: const TextStyle(
color: Color(0xFFB0B0B6),
fontSize: 10,
fontWeight: FontWeight.w400,
height: 1.2,
),
),
),
),
],
);
},
),
);
}
}
class _TrendLinesPainter extends CustomPainter {
const _TrendLinesPainter({
required this.maxY,
... ... @@ -91,6 +289,15 @@ class _TrendLinesPainter extends CustomPainter {
strokeWidth: 1,
);
}
if (maxY % 100 != 0) {
_drawDashedLine(
canvas,
size.width,
_yFor(maxY, size.height),
color: _gridColor,
strokeWidth: 1,
);
}
if (average > 0) {
_drawSolidLine(
canvas,
... ...
import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import '../../../../r.dart';
import '../../report_common/widgets/chart_selection_line_overlay.dart';
import '../../report_common/widgets/health_report_subject_scope.dart';
import '../../report_common/utils/report_localization.dart';
import '../models/activity_burn_report_models.dart';
import 'activity_burn_duration_text.dart';
import 'activity_burn_ring.dart';
import 'activity_burn_trend_lines.dart';
... ... @@ -49,6 +53,14 @@ class _WeeklySummary extends StatelessWidget {
@override
Widget build(BuildContext context) {
final exercise = activityBurnDurationText(
context,
report.hasData ? report.totalExerciseMinutes : null,
);
final stand = activityBurnDurationText(
context,
report.hasData ? report.totalStandHours : null,
);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 2),
child: Column(
... ... @@ -108,18 +120,16 @@ class _WeeklySummary extends StatelessWidget {
_SummaryMetric(
title: HealthReportSubjectScope.titleOf(
context, context.l10n.activityExerciseTotalDuration),
value: report.hasData
? report.totalExerciseMinutes.toString()
: '-',
unit: context.l10n.reportUnitMinute,
value: exercise.value,
unit: exercise.unit,
color: ActivityBurnWeekReportView._exercise,
),
const SizedBox(width: 34),
_SummaryMetric(
title: HealthReportSubjectScope.titleOf(
context, context.l10n.activityStandTotalDuration),
value: report.hasData ? report.totalStandHours.toString() : '-',
unit: context.l10n.reportUnitHour,
value: stand.value,
unit: stand.unit,
color: ActivityBurnWeekReportView._stand,
),
],
... ... @@ -382,12 +392,17 @@ class _EnergyTrendCard extends StatefulWidget {
}
class _EnergyTrendCardState extends State<_EnergyTrendCard> {
static const _chartTopInset = 8.0;
static const _barColor = Color(0xFFFF5279);
int? _touchedIndex;
Offset? _touchedOffset;
@override
Widget build(BuildContext context) {
final maxY = _maxY;
final comparisonPercent = widget.report.activeEnergyComparisonPercent;
final hasChartReference = _hasChartReference;
return Container(
height: 344,
padding: const EdgeInsets.fromLTRB(20, 20, 14, 32),
... ... @@ -407,7 +422,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 20),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
... ... @@ -434,10 +449,18 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
const SizedBox(height: 2),
Row(
children: [
if (comparisonPercent != null) ...[
Image.asset(
comparisonPercent >= 0
? R.assetsImagesHealthTrendUp
: R.assetsImagesHealthTrendDown,
width: 12,
height: 12,
),
const SizedBox(width: 2),
],
Text(
widget.report.hasData
? context.l10n.activityComparedLastWeek
: context.l10n.activityComparedUnavailable,
_comparisonText(context, comparisonPercent),
style: const TextStyle(
color: ActivityBurnWeekReportView._h2,
fontSize: 12,
... ... @@ -463,40 +486,45 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
children: [
Positioned(
left: 0,
right: 6,
top: 0,
bottom: 42,
child: ActivityBurnTrendLines(
right: 0,
top: _chartTopInset,
bottom: 0,
child: ActivityBurnTrendPlotFrame(
maxY: maxY,
average: widget.report.averageDailyActiveEnergy.toDouble(),
target: widget.report.activeEnergyGoal.toDouble(),
),
),
BarChart(_chartData(maxY)),
const Positioned(
left: 0,
right: 6,
bottom: 34,
child: SizedBox(
height: 1,
child: ActivityBurnAxisDashedLine(),
child: LayoutBuilder(
builder: (context, constraints) => BarChart(
_chartData(maxY, constraints.maxWidth),
),
),
),
),
if (_touchedOffset != null)
Positioned.fill(
top: _chartTopInset,
child: ChartSelectionLineOverlay(
offset: _touchedOffset!,
color: ActivityBurnWeekReportView._h3,
bottomTitleHeight: 32,
bottomTitleHeight:
ActivityBurnTrendPlotFrame.bottomTitleHeight,
tooltipMargin: 6,
plotLeft: ActivityBurnTrendPlotFrame.horizontalInset,
plotRight: ActivityBurnTrendPlotFrame.rightAxisWidth,
),
),
if (!widget.report.hasData)
Text(
context.l10n.reportWaitingForData,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
if (!hasChartReference)
Positioned.fill(
top: _chartTopInset,
bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight,
child: Center(
child: Text(
context.l10n.reportWaitingForData,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
),
),
),
),
],
... ... @@ -511,19 +539,42 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
final maxValue = widget.report.days
.map((day) => day.activeEnergy?.value ?? 0)
.fold<int>(0, (max, value) => value > max ? value : max);
final referenceMax = [
maxValue,
widget.report.averageDailyActiveEnergy,
widget.report.activeEnergyGoal,
].reduce((max, value) => value > max ? value : max);
return ((referenceMax / 100).ceil().clamp(4, 9) * 100).toDouble();
final maxReference = math.max(maxValue, widget.report.activeEnergyGoal);
return _roundUpToFiveOrZero(maxReference)
.clamp(100, double.infinity)
.toDouble();
}
bool get _hasChartReference =>
widget.report.days.any((day) => (day.activeEnergy?.value ?? 0) > 0) ||
widget.report.activeEnergyGoal > 0;
String _comparisonText(BuildContext context, int? percent) {
if (percent == null || !widget.report.hasData) {
return context.l10n.activityComparedUnavailable;
}
if (percent == 0) return '与上周持平';
final direction = percent > 0 ? '多' : '少';
return '比上周$direction${percent.abs()}%';
}
double _barToY(int value, double maxY) {
if (value <= 0) return 0;
return math.max(value.toDouble(), maxY * 0.012);
}
BarChartData _chartData(double maxY) {
double _roundUpToFiveOrZero(int value) {
if (value <= 0) return 100;
return (value / 5).ceil() * 5.0;
}
BarChartData _chartData(double maxY, double plotWidth) {
final hasData = widget.report.hasData;
return BarChartData(
minY: 0,
maxY: maxY,
alignment: BarChartAlignment.spaceBetween,
groupsSpace: _barGroupsSpace(plotWidth, widget.report.days.length, 16),
gridData: FlGridData(
show: false,
),
... ... @@ -586,10 +637,13 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
showTitles: false,
reservedSize: 36,
interval: 100,
getTitlesWidget: (value, meta) {
final isHundredTick = value.round() % 100 == 0;
final isMax = (value - maxY).abs() < 0.001;
if (!isHundredTick && !isMax) return const SizedBox.shrink();
return Transform.translate(
offset: const Offset(0, -8),
child: Padding(
... ... @@ -609,7 +663,8 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 42,
reservedSize: ActivityBurnTrendPlotFrame.bottomTitleHeight,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= widget.report.days.length) {
... ... @@ -617,7 +672,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
}
final day = widget.report.days[index];
return Padding(
padding: const EdgeInsets.only(top: 14),
padding: const EdgeInsets.only(top: 4),
child: Column(
children: [
Text(
... ... @@ -625,6 +680,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
style: const TextStyle(
color: ActivityBurnWeekReportView._h3,
fontSize: 10,
height: 1.1,
),
),
Text(
... ... @@ -632,6 +688,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
style: const TextStyle(
color: ActivityBurnWeekReportView._h3,
fontSize: 10,
height: 1.1,
),
),
],
... ... @@ -649,12 +706,14 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
barRods: [
BarChartRodData(
fromY: 0,
toY:
(widget.report.days[i].activeEnergy?.value ?? 0).toDouble(),
toY: _barToY(
widget.report.days[i].activeEnergy?.value ?? 0,
maxY,
),
width: 16,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(8)),
color: ActivityBurnWeekReportView._active,
color: _barColor,
backDrawRodData: BackgroundBarChartRodData(show: false),
),
],
... ... @@ -671,6 +730,12 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
}
}
double _barGroupsSpace(double plotWidth, int count, double barWidth) {
if (count <= 1) return 0;
final space = (plotWidth - barWidth * count) / (count - 1);
return space < 0 ? 0 : space;
}
class _TrendLegend extends StatelessWidget {
const _TrendLegend({
required this.color,
... ...
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/logging/app_logger.dart';
import 'package:doublefeel_flutter/data/local/local_storage.dart';
import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../widgets/app_review_prompt_dialog.dart';
class AppReviewPromptLogic {
AppReviewPromptLogic({required LocalStorage storage}) : _storage = storage;
AppReviewPromptLogic({
required LocalStorage storage,
PlatformHostApi? platformHostApi,
}) : _storage = storage,
_platformHostApi = platformHostApi ?? PlatformHostApi();
final LocalStorage _storage;
final PlatformHostApi _platformHostApi;
bool _isShowing = false;
... ... @@ -33,6 +41,7 @@ class AppReviewPromptLogic {
switch (result) {
case AppReviewPromptResult.liked:
await _coolDown(_likedCooldown);
await _requestAppReview();
case AppReviewPromptResult.feedback:
await _coolDown(_feedbackCooldown);
if (context.mounted) {
... ... @@ -46,6 +55,16 @@ class AppReviewPromptLogic {
}
}
Future<void> _requestAppReview() async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) return;
try {
await _platformHostApi.requestAppReview();
} catch (error, stackTrace) {
AppLogger.w('Request app review failed', error, stackTrace);
}
}
Future<void> _showFeedbackPrompt(BuildContext context) async {
final result = await showDialog<AppReviewPromptResult>(
context: context,
... ...
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
import '../../../../r.dart';
enum AppReviewPromptResult {
liked,
feedback,
... ... @@ -13,11 +15,13 @@ class AppReviewPromptDialog extends StatelessWidget {
Widget build(BuildContext context) {
return _ReviewPromptCard(
height: 380,
illustrationAsset: R.assetsImagesAppReviewGoodBg,
title: context.l10n.appReviewPromptTitle,
message: context.l10n.appReviewPromptMessage,
primaryText: context.l10n.appReviewPromptLikeAction,
secondaryText: context.l10n.appReviewPromptFeedbackAction,
onPrimaryTap: () => Navigator.of(context).pop(AppReviewPromptResult.liked),
onPrimaryTap: () =>
Navigator.of(context).pop(AppReviewPromptResult.liked),
onSecondaryTap: () =>
Navigator.of(context).pop(AppReviewPromptResult.feedback),
);
... ... @@ -31,6 +35,7 @@ class AppReviewFeedbackDialog extends StatelessWidget {
Widget build(BuildContext context) {
return _ReviewPromptCard(
height: 425,
illustrationAsset: R.assetsImagesAppReviewBadBg,
title: context.l10n.appReviewFeedbackTitle,
message: context.l10n.appReviewFeedbackMessage,
primaryText: context.l10n.appReviewFeedbackSendAction,
... ... @@ -45,6 +50,7 @@ class AppReviewFeedbackDialog extends StatelessWidget {
class _ReviewPromptCard extends StatelessWidget {
const _ReviewPromptCard({
required this.height,
required this.illustrationAsset,
required this.title,
required this.message,
required this.primaryText,
... ... @@ -54,6 +60,7 @@ class _ReviewPromptCard extends StatelessWidget {
});
final double height;
final String illustrationAsset;
final String title;
final String message;
final String primaryText;
... ... @@ -63,6 +70,7 @@ class _ReviewPromptCard extends StatelessWidget {
static const _cardWidth = 300.0;
static const _illustrationHeight = 155.0;
static const _dialogAlignment = Alignment(0, -0.12);
static const _brandColor = Color(0xFF845EEE);
static const _h1 = Color(0xFF0F0F11);
static const _h2 = Color(0xFF78787D);
... ... @@ -72,7 +80,8 @@ class _ReviewPromptCard extends StatelessWidget {
final isFeedback = height > 400;
return PopScope(
canPop: false,
child: Center(
child: Align(
alignment: _dialogAlignment,
child: Material(
color: Colors.transparent,
child: Container(
... ... @@ -100,18 +109,9 @@ class _ReviewPromptCard extends StatelessWidget {
left: 0,
right: 0,
height: _illustrationHeight,
child: ColoredBox(
color: const Color(0xFFF09AB8),
child: Center(
child: Text(
context.l10n.appReviewIllustrationPlaceholder,
style: const TextStyle(
color: Colors.red,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
),
child: Image.asset(
illustrationAsset,
fit: BoxFit.cover,
),
),
Positioned(
... ...
import 'package:get/get.dart';
import '../../../../data/local/user_preferences_storage.dart';
import '../controllers/developer_options_controller.dart';
class DeveloperOptionsBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<DeveloperOptionsController>(
() => DeveloperOptionsController(Get.find<UserPreferencesStorage>()),
);
}
}
... ...
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../../data/local/user_preferences_storage.dart';
import '../../../routes/app_pages.dart';
class DeveloperOptionsItem {
const DeveloperOptionsItem({
required this.title,
required this.routeName,
});
final String title;
final String routeName;
}
class DeveloperOptionsController extends GetxController {
DeveloperOptionsController(this._userPreferencesStorage);
final UserPreferencesStorage _userPreferencesStorage;
final TextEditingController tokenController = TextEditingController();
final List<DeveloperOptionsItem> options = const [
DeveloperOptionsItem(
title: 'User Information',
routeName: Routes.USER_INFORMATION,
),
DeveloperOptionsItem(
title: 'Route List',
routeName: Routes.ROUTE_LIST,
),
DeveloperOptionsItem(
title: 'Token Login',
routeName: Routes.TOKEN_LOGIN,
),
];
@override
void onInit() {
super.onInit();
tokenController.text = _userPreferencesStorage.accessToken;
}
Future<void> saveAccessToken() async {
final token = tokenController.text.trim();
await _userPreferencesStorage.updateAccessToken(token);
Get.offAllNamed(AppRoutes.home);
}
@override
void onClose() {
tokenController.dispose();
super.onClose();
}
}
... ...
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/developer_options_controller.dart';
class DeveloperOptionsView extends GetView<DeveloperOptionsController> {
const DeveloperOptionsView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Developer options')),
body: ListView.separated(
itemCount: controller.options.length,
itemBuilder: (context, index) {
final item = controller.options[index];
return ListTile(
title: Text(item.title),
trailing: const Icon(Icons.chevron_right),
onTap: () => Get.toNamed(item.routeName),
);
},
separatorBuilder: (context, index) => const Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: Divider(
height: 1,
color: Color(0x66CCCCCC),
),
),
),
);
}
}
... ...
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/developer_options_controller.dart';
class TokenLoginView extends GetView<DeveloperOptionsController> {
const TokenLoginView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Token Login')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: controller.tokenController,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Access token',
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: controller.saveAccessToken,
child: const Text('Login'),
),
],
),
),
);
}
}
... ...
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class UserInformationView extends StatelessWidget {
const UserInformationView({super.key});
@override
Widget build(BuildContext context) {
final userPreferencesStorage = Get.find<UserPreferencesStorage>();
return Scaffold(
appBar: AppBar(title: const Text('User Information')),
body: Obx(() {
final preferences = userPreferencesStorage.preferences.value;
final userInfo = preferences.meUserInfo;
return ListView(
padding: const EdgeInsets.all(16),
children: [
_InfoRow(label: 'userid', value: '${userInfo?.id ?? ''}'),
const Divider(height: 1, color: Color(0x66CCCCCC)),
_InfoRow(label: 'nickname', value: userInfo?.nickname ?? ''),
const Divider(height: 1, color: Color(0x66CCCCCC)),
_InfoRow(label: 'access token', value: preferences.accessToken),
],
);
}),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
),
),
const SizedBox(height: 6),
SelectableText(
value,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
);
}
}
... ...
... ... @@ -18,7 +18,10 @@ class RouteListView extends GetView<RouteListController> {
@override
Widget build(BuildContext context) {
return _buildBody(context);
return Scaffold(
appBar: AppBar(title: const Text('Route List')),
body: _buildBody(context),
);
}
Widget _buildBody(BuildContext context) {
... ... @@ -38,7 +41,6 @@ class RouteListView extends GetView<RouteListController> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
_buildUserInfoView(),
Expanded(
child: Container(
width: double.infinity,
... ... @@ -60,61 +62,6 @@ class RouteListView extends GetView<RouteListController> {
);
}
Widget _buildUserInfoView() {
const textColor = Color(0xFFA6A6AF);
// if (!Get.isRegistered<UserInfoService>()) {
// return Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text(
// "userId: ",
// style: TextStyle(
// color: textColor,
// fontSize: 12.w,
// fontWeight: FontWeightExt.semibold,
// ),
// ),
// Text(
// "nickName: ",
// style: TextStyle(
// color: textColor,
// fontSize: 12.w,
// fontWeight: FontWeightExt.semibold,
// ),
// ),
// ],
// );
// }
// final userinfoService = Get.find<UserInfoService>();
// return Obx(() {
// final userInfo = userinfoService.lovaryUserInfo.value;
// return Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text(
// "userId: ${userInfo?.id ?? ''}",
// style: TextStyle(
// color: textColor,
// fontSize: 12.w,
// fontWeight: FontWeightExt.semibold,
// ),
// ),
// Text(
// "nickName: ${userInfo?.nickname ?? ''}",
// style: TextStyle(
// color: textColor,
// fontSize: 12.w,
// fontWeight: FontWeightExt.semibold,
// ),
// ),
// ],
// );
// });
return Container();
}
Widget _buildRouteItemView(RouteItem routeItem) {
double leftPadding;
if (routeItem.parent == null) {
... ...
import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
... ... @@ -14,7 +15,7 @@ class AddFriendController extends GetxController {
final RxString friendCodeInput = ''.obs;
final RxBool isSubmitting = false.obs;
final RxString myInviteCode = 'AC1192'.obs;
final RxString myInviteCode = ''.obs;
@override
void onInit() {
... ... @@ -22,6 +23,9 @@ class AddFriendController extends GetxController {
friendCodeController.addListener(() {
friendCodeInput.value = friendCodeController.text.trim();
});
final userPrefs = Get.find<UserPreferencesStorage>();
myInviteCode.value =
userPrefs.preferences.value.meUserInfo?.uniqueCode ?? '';
}
@override
... ...
... ... @@ -38,4 +38,10 @@ class FriendTrendController extends GetxController with HealthTrendControl {
period: selectedPeriod.value,
date: selectedDate.value,
);
HealthReportQuery queryForType(int typeIndex) => HealthReportQuery(
targetUserId: friendItem?.id,
period: periodForType(typeIndex),
date: dateForType(typeIndex),
);
}
... ...
... ... @@ -64,13 +64,17 @@ class FriendsRepositoryImpl implements FriendsRepository {
FriendHealthData _mapFriend(api_models.FriendItem friend) {
final healthData = friend.healthData;
final stressValue = healthData?.latestHrv;
final name = friend.remarkName?.trim();
final nickname = friend.friendNickname?.trim();
final remark = friend.remarkName?.trim();
return FriendHealthData(
friendItem: friend,
userId: friend.friendUserId,
avatarUrl: friend.avatar,
name: name == null || name.isEmpty ? '未知好友' : name,
name: nickname == null || nickname.isEmpty
? l10n.friendsUnknownFriend
: nickname,
remark: remark == null || remark.isEmpty ? null : remark,
updatedAt: _updatedAt(friend.updateTime),
sleepQualityScore: healthData?.sleepEvaluate,
steps: healthData?.totalSteps == null
... ...
import 'package:doublefeel_flutter/data/models/friend/friend_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
... ... @@ -34,8 +35,9 @@ class FriendTrendView extends GetView<FriendTrendController> {
),
titleSpacing: 0,
title: _FriendTrendTitle(
title: context.l10n
.friendsTrendTitle(controller.friendItem?.remarkName ?? ""),
title: context.l10n.friendsTrendTitle(
_friendDisplayName(controller.friendItem),
),
avatarUrl: controller.friendItem?.avatar ?? "",
),
),
... ... @@ -58,6 +60,7 @@ class FriendTrendView extends GetView<FriendTrendController> {
child: Obx(
() => HealthTrendContent(
query: controller.query,
queryForType: controller.queryForType,
selectedTypeIndex: controller.selectedTypeIndex.value,
onTypeChanged: controller.changeType,
onPeriodChanged: controller.changePeriod,
... ... @@ -71,6 +74,16 @@ class FriendTrendView extends GetView<FriendTrendController> {
),
);
}
String _friendDisplayName(FriendItem? friend) {
final remark = friend?.remarkName?.trim();
if (remark != null && remark.isNotEmpty) return remark;
final nickname = friend?.friendNickname?.trim();
if (nickname != null && nickname.isNotEmpty) return nickname;
return l10n.friendsUnknownFriend;
}
}
class _FriendTrendTitle extends StatelessWidget {
... ...
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/app/modules/hrv_report/models/hrv_report_models.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/constants/intent_keys.dart';
import 'package:doublefeel_flutter/core/util/size_extensions.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
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:intl/intl.dart';
... ... @@ -172,7 +173,10 @@ class FriendsTab extends GetView<FriendsController> {
}
Future<void> _handleAddFriend() async {
final didAddFriend = await Get.toNamed(Routes.ADD_FRIEND);
final Type type = FriendsTab;
final String name = type.toString();
final didAddFriend = await Get.toNamed(AppRoutes.bindPartner,
arguments: {IntentKeys.keyFrom: name});
if (didAddFriend == true) await controller.loadFriends();
}
... ... @@ -328,26 +332,21 @@ class _EmptyFriendsView extends StatelessWidget {
padding: const EdgeInsets.only(top: 70),
child: Column(
children: [
Container(
SizedBox(
width: 240,
height: 180,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
),
child: const Center(
child: Icon(
Icons.favorite_border_rounded,
size: 68,
color: Color(0xFFD8D2EA),
),
child: Image.asset(
R.assetsImagesFriendsListNoDataIcon,
width: double.infinity,
height: double.infinity,
fit: BoxFit.contain,
),
),
const SizedBox(height: 16),
Text(
context.l10n.friendsAddCloseContactDescription,
style: const TextStyle(
color: Color(0xFF78787D),
color: Color(0xFF845EEE),
fontSize: 12,
fontWeight: FontWeight.w400,
),
... ...
... ... @@ -187,13 +187,15 @@ class _FriendCardHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final friendRemark = remark?.trim();
final hasRemark = friendRemark?.isNotEmpty == true;
final primaryName = !isSelf && hasRemark ? friendRemark! : name;
final friendRemark = remark?.trim() ?? "";
final nickname = name.trim();
final hasRemark = friendRemark.isNotEmpty;
final primaryName = !isSelf && hasRemark ? friendRemark : nickname;
final suffix = isSelf
? context.l10n.friendsMe
: hasRemark
? name
? nickname
: null;
return Row(
... ... @@ -371,8 +373,8 @@ class _StatusFigure extends StatelessWidget {
child: Image.asset(
stressLevel?.realtimeStressIconPath ??
R.assetsImagesRealtimeStressNoDataIcon,
width: 80,
height: 80,
width: 72,
height: 72,
fit: BoxFit.contain,
),
),
... ...
... ... @@ -4,25 +4,55 @@ import '../../report_common/config/report_date_range_config.dart';
import '../../report_common/models/report_period.dart';
mixin HealthTrendControl on GetxController {
static const int typeCount = 3;
final selectedTypeIndex = 0.obs;
final selectedPeriod = ReportPeriod.week.obs;
final selectedDate = ReportDateRangeConfig.clampDate(DateTime.now()).obs;
final List<ReportPeriod> _periodsByType = List.of(const [
ReportPeriod.week,
ReportPeriod.day,
ReportPeriod.day,
]);
final List<DateTime> _datesByType = List.generate(
typeCount,
(_) => ReportDateRangeConfig.clampDate(DateTime.now()),
);
void changeType(int index) {
final nextIndex = normalizeTypeIndex(index);
selectedTypeIndex.value = nextIndex;
selectedPeriod.value = normalizePeriod(nextIndex, selectedPeriod.value);
_periodsByType[nextIndex] =
normalizePeriod(nextIndex, _periodsByType[nextIndex]);
selectedPeriod.value = _periodsByType[nextIndex];
selectedDate.value = _datesByType[nextIndex];
}
void changePeriod(ReportPeriod period) {
selectedPeriod.value = normalizePeriod(selectedTypeIndex.value, period);
final typeIndex = normalizeTypeIndex(selectedTypeIndex.value);
final nextPeriod = normalizePeriod(typeIndex, period);
_periodsByType[typeIndex] = nextPeriod;
selectedPeriod.value = nextPeriod;
}
void changeDate(DateTime date) {
selectedDate.value = ReportDateRangeConfig.clampDate(date);
final typeIndex = normalizeTypeIndex(selectedTypeIndex.value);
final nextDate = ReportDateRangeConfig.clampDate(date);
_datesByType[typeIndex] = nextDate;
selectedDate.value = nextDate;
}
ReportPeriod periodForType(int typeIndex) {
final normalizedIndex = normalizeTypeIndex(typeIndex);
return normalizePeriod(normalizedIndex, _periodsByType[normalizedIndex]);
}
DateTime dateForType(int typeIndex) {
return _datesByType[normalizeTypeIndex(typeIndex)];
}
int normalizeTypeIndex(int index) => index >= 0 && index < 3 ? index : 0;
int normalizeTypeIndex(int index) =>
index >= 0 && index < typeCount ? index : 0;
ReportPeriod normalizePeriod(int typeIndex, ReportPeriod period) {
final periods = supportedPeriods(normalizeTypeIndex(typeIndex));
... ...
... ... @@ -22,6 +22,7 @@ class HealthTrendContent extends StatefulWidget {
required this.onTypeChanged,
required this.onPeriodChanged,
required this.onDateChanged,
this.queryForType,
});
final HealthReportQuery query;
... ... @@ -29,6 +30,7 @@ class HealthTrendContent extends StatefulWidget {
final ValueChanged<int> onTypeChanged;
final ValueChanged<ReportPeriod> onPeriodChanged;
final ValueChanged<DateTime> onDateChanged;
final HealthReportQuery Function(int typeIndex)? queryForType;
@override
State<HealthTrendContent> createState() => _HealthTrendContentState();
... ... @@ -86,6 +88,10 @@ class _HealthTrendContentState extends State<HealthTrendContent>
super.dispose();
}
HealthReportQuery _queryForType(int typeIndex) {
return widget.queryForType?.call(typeIndex) ?? widget.query;
}
@override
Widget build(BuildContext context) {
final userPrefs = Get.find<UserPreferencesStorage>();
... ... @@ -106,7 +112,7 @@ class _HealthTrendContentState extends State<HealthTrendContent>
children: [
_KeepAliveWrapper(
child: _HrvTrendSection(
query: widget.query,
query: _queryForType(0),
isVip: isVip,
onSubscribe: openPurchase,
onPeriodChanged: widget.onPeriodChanged,
... ... @@ -115,7 +121,7 @@ class _HealthTrendContentState extends State<HealthTrendContent>
),
_KeepAliveWrapper(
child: _ActivityBurnTrendSection(
query: widget.query,
query: _queryForType(1),
isVip: isVip,
onSubscribe: openPurchase,
onPeriodChanged: widget.onPeriodChanged,
... ... @@ -124,7 +130,7 @@ class _HealthTrendContentState extends State<HealthTrendContent>
),
_KeepAliveWrapper(
child: _SleepTrendSection(
query: widget.query,
query: _queryForType(2),
isVip: isVip,
onSubscribe: openPurchase,
onPeriodChanged: widget.onPeriodChanged,
... ...
... ... @@ -3,6 +3,7 @@ import 'package:doublefeel_flutter/data/local/local_storage.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../routes/app_pages.dart';
import '../../app_review_prompt/logic/app_review_prompt_logic.dart';
import '../../report_common/models/report_period.dart';
import 'trend/trend_controller.dart';
... ... @@ -13,6 +14,7 @@ class HomeController extends GetxController {
final AppReviewPromptLogic _appReviewPromptLogic = AppReviewPromptLogic(
storage: Get.find<LocalStorage>(),
);
final LocalStorage _storage = Get.find<LocalStorage>();
final UserStateService userStateService = Get.find<UserStateService>();
/// 当前选中的底部 tab 索引
... ... @@ -22,6 +24,25 @@ class HomeController extends GetxController {
return _appReviewPromptLogic.maybeShowPrompt(context);
}
Future<bool> maybeShowMembershipOffer() async {
if (userStateService.isVip) return false;
final userId = userStateService.userId;
if (userId <= 0) return false;
final today = _dateKey(DateTime.now());
final alreadyShownToday = _storage.homeMembershipOfferPromptDate == today;
final sameAccount = _storage.homeMembershipOfferPromptUserId == userId;
if (alreadyShownToday && sameAccount) return false;
await _storage.setHomeMembershipOfferPromptState(
date: today,
userId: userId,
);
await Get.toNamed(Routes.PURCHASE);
return true;
}
void changeTab(int index) {
selectedIndex.value = index;
}
... ... @@ -30,4 +51,7 @@ class HomeController extends GetxController {
Get.find<TrendController>().selectType(type, date: date, period: period);
changeTab(trendTabIndex);
}
int _dateKey(DateTime date) =>
date.year * 10000 + date.month * 100 + date.day;
}
... ...
... ... @@ -24,6 +24,12 @@ class TrendController extends GetxController with HealthTrendControl {
date: selectedDate.value,
);
HealthReportQuery queryForType(int typeIndex) => HealthReportQuery(
targetUserId: targetUserId.value,
period: periodForType(typeIndex),
date: dateForType(typeIndex),
);
void selectType(TrendType type, {DateTime? date, ReportPeriod? period}) {
changeType(type.tabIndex);
if (period != null) {
... ...
... ... @@ -62,7 +62,9 @@ class _HomeReviewPromptGateState extends State<_HomeReviewPromptGate> {
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return;
await widget.controller.maybeShowMembershipOffer();
if (!mounted) return;
widget.controller.maybeShowAppReviewPrompt(context);
});
... ...
... ... @@ -31,6 +31,7 @@ class _HomeTrendBody extends GetView<TrendController> {
return Obx(
() => HealthTrendContent(
query: controller.query,
queryForType: controller.queryForType,
selectedTypeIndex: controller.selectedTypeIndex.value,
onTypeChanged: controller.changeType,
onPeriodChanged: controller.changePeriod,
... ...
... ... @@ -13,7 +13,6 @@ import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/local/user_preferences.dart';
import 'package:doublefeel_flutter/data/models/user/user_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
... ... @@ -79,9 +78,9 @@ class MyTab extends GetView<MyController> {
if (environmentConfig.isDebug) ...[
const SizedBox(height: 12),
_SettingsRow(
title: 'Route List',
title: 'Developer options',
onTap: () {
Get.toNamed(Routes.ROUTE_LIST);
Get.toNamed(Routes.DEVELOPER_OPTIONS);
},
),
],
... ...
... ... @@ -148,7 +148,7 @@ class _BottomSheetFriendRow extends StatelessWidget {
// Name
Expanded(
child: Text(
friend.remarkName ?? '未知好友',
_friendDisplayName(friend),
style: const TextStyle(
color: Color(0xFF0F0F11),
fontSize: 14,
... ... @@ -182,4 +182,14 @@ class _BottomSheetFriendRow extends StatelessWidget {
),
);
}
String _friendDisplayName(FriendItem friend) {
final remark = friend.remarkName?.trim();
if (remark != null && remark.isNotEmpty) return remark;
final nickname = friend.friendNickname?.trim();
if (nickname != null && nickname.isNotEmpty) return nickname;
return l10n.friendsUnknownFriend;
}
}
... ...
... ... @@ -305,7 +305,10 @@ class _YearBarChartState extends State<_YearBarChart> {
}
class _MonthExtreme extends StatelessWidget {
const _MonthExtreme({required this.label, required this.months});
const _MonthExtreme({
required this.label,
required this.months,
});
final String label;
final List<int> months;
... ... @@ -313,10 +316,9 @@ class _MonthExtreme extends StatelessWidget {
Widget build(BuildContext context) {
final separator =
Localizations.localeOf(context).languageCode == 'zh' ? ',' : ', ';
final value = [
for (var i = 0; i < 2; i++)
i < months.length ? reportMonth(months[i]) : '-月',
].join(separator);
final value = months.isEmpty
? '-月'
: months.map((month) => reportMonth(month)).join(separator);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
... ...
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
class PremiumActivatedController extends GetxController {
... ... @@ -11,7 +12,11 @@ class PremiumActivatedController extends GetxController {
super.onClose();
}
executeBackLogic() {
executeBackLogic(BuildContext context) {
Get.back();
}
executeContinueLogic(BuildContext context) {
Get.back();
}
}
... ...
... ... @@ -43,7 +43,7 @@ class PremiumActivatedView extends GetView<PremiumActivatedController> {
highlightColor: Colors.transparent,
padding: EdgeInsets.zero,
onPressed: () {
controller.executeBackLogic();
controller.executeBackLogic(context);
},
icon: Image(
width: 24.w,
... ... @@ -63,12 +63,9 @@ class PremiumActivatedView extends GetView<PremiumActivatedController> {
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: double.infinity,
height: 360.h,
color: Colors.red,
),
SizedBox(height: 20),
SizedBox(height: 150.h),
const _FloatingPremiumIcon(),
SizedBox(height: 84.h),
Text(
context.l10n.premiumActivatedTitle,
style: const TextStyle(
... ... @@ -85,22 +82,86 @@ class PremiumActivatedView extends GetView<PremiumActivatedController> {
textAlign: TextAlign.center),
),
Expanded(child: Container()),
Container(
width: 280.dp,
height: 48.dp,
decoration: BoxDecoration(
color: Color(0xFF845EEE),
borderRadius: BorderRadius.circular(24.dp),
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)),
),
alignment: Alignment.center,
child: Text(context.l10n.premiumActivatedContinue,
style: const TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeightExt.bold)),
),
SizedBox(height: 36),
],
);
}
}
class _FloatingPremiumIcon extends StatefulWidget {
const _FloatingPremiumIcon();
@override
State<_FloatingPremiumIcon> createState() => _FloatingPremiumIconState();
}
class _FloatingPremiumIconState extends State<_FloatingPremiumIcon>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _offsetY;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
)..repeat(reverse: true);
_offsetY = Tween<double>(begin: 0, end: -10).animate(
CurvedAnimation(parent: _controller, curve: Curves.linear),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _offsetY,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, _offsetY.value),
child: child,
);
},
child: Container(
width: 88,
height: 88,
decoration: const BoxDecoration(
color: Color(0xFF845EEE),
shape: BoxShape.circle,
),
padding: const EdgeInsets.all(5.0),
child: Image.asset(
R.assetsImagesProIcon,
width: double.infinity,
height: double.infinity,
fit: BoxFit.contain,
),
),
);
}
}
... ...
import 'package:doublefeel_flutter/app/widget/premium_benefit_list_view.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:doublefeel_flutter/r.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import '../controllers/purchase_controller.dart';
import '../widgets/purchase_benefit_list.dart';
import '../widgets/purchase_bottom_bar.dart';
import '../widgets/purchase_colors.dart';
import '../widgets/purchase_plan_card.dart';
... ... @@ -13,45 +14,6 @@ import '../widgets/refund_explanation_bottom_sheet.dart';
class PurchaseView extends GetView<PurchaseController> {
const PurchaseView({super.key});
List<PurchaseBenefit> _benefits(BuildContext context) => [
PurchaseBenefit(
title: context.l10n.purchaseBenefitRealtimeStress,
icon: Icons.monitor_heart_outlined,
),
PurchaseBenefit(
title: context.l10n.purchaseBenefitStressTrends,
icon: Icons.query_stats,
),
PurchaseBenefit(
title: context.l10n.purchaseBenefitActivityTrends,
icon: Icons.local_fire_department_outlined,
),
PurchaseBenefit(
title: context.l10n.purchaseBenefitSleepReports,
icon: Icons.bed_outlined,
),
PurchaseBenefit(
title: context.l10n.purchaseBenefitHealthSync,
icon: Icons.sync,
),
PurchaseBenefit(
title: context.l10n.purchaseBenefitContactNotifications,
icon: Icons.near_me_outlined,
),
PurchaseBenefit(
title: context.l10n.purchaseBenefitCustomWatchFace,
icon: Icons.watch_outlined,
),
PurchaseBenefit(
title: context.l10n.purchaseBenefitSleepAnalysis,
icon: Icons.nightlight_round,
),
PurchaseBenefit(
title: context.l10n.purchaseBenefitFutureFeatures,
icon: Icons.layers_outlined,
),
];
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
... ... @@ -92,7 +54,7 @@ class PurchaseView extends GetView<PurchaseController> {
const SizedBox(height: 22),
_SectionTitle(context.l10n.purchaseBenefitsTitle),
const SizedBox(height: 14),
PurchaseBenefitList(benefits: _benefits(context)),
const PremiumBenefitListView(),
const SizedBox(height: 24),
_LegalNotes(controller: controller),
],
... ... @@ -134,28 +96,11 @@ class _HeroHeader extends StatelessWidget {
return SizedBox(
height: 320,
width: double.infinity,
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFFF8898),
Color(0xFFFF9BA8),
PurchaseColors.background,
],
stops: [0, 0.76, 1],
),
),
child: Align(
alignment: const Alignment(0.55, 0.18),
child: Image.asset(
'assets/images/today/premium_gift_illustration.png',
width: 150,
height: 164,
fit: BoxFit.contain,
),
),
child: Image.asset(
R.assetsImagesPurchaseTopBg,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
);
}
... ...
import 'package:flutter/material.dart';
import 'purchase_colors.dart';
class PurchaseBenefit {
const PurchaseBenefit({required this.title, required this.icon});
final String title;
final IconData icon;
}
class PurchaseBenefitList extends StatelessWidget {
const PurchaseBenefitList({super.key, required this.benefits});
final List<PurchaseBenefit> benefits;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
for (final benefit in benefits)
SizedBox(
height: 34,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Icon(benefit.icon, size: 16, color: PurchaseColors.brand),
const SizedBox(width: 8),
Expanded(
child: Text(
benefit.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: PurchaseColors.title,
fontSize: 13,
height: 1.2,
),
),
),
const SizedBox(width: 8),
const Icon(
Icons.check,
size: 18,
color: PurchaseColors.brand,
),
],
),
),
),
],
),
);
}
}
... ... @@ -352,15 +352,6 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
final sleepEnd = asleepTime == null || durationSeconds == null
? null
: asleepTime.add(Duration(seconds: durationSeconds.round()));
final pointsInSleepRange = asleepTime == null
? points
: points
.where((point) =>
!point.time.isBefore(asleepTime) &&
(sleepEnd == null || !point.time.isAfter(sleepEnd)))
.toList();
final sleepPoints =
pointsInSleepRange.isEmpty ? points : pointsInSleepRange;
return SleepReport(
date: date,
duration: durationSeconds == null
... ... @@ -368,7 +359,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
: SleepDuration(minutes: (durationSeconds / 60).round()),
qualityScore: trend?.score?.round(),
heartRate: _heartRateSummary(
sleepPoints,
points,
asleepTime,
sleepEnd,
averageBpm: averageBpm,
... ...
... ... @@ -237,36 +237,47 @@ class WeeklySleepReport {
averageScore != null;
int? get averageDurationMinutes {
if (averageDurationSeconds != null) {
return (averageDurationSeconds! / 60).round();
}
final averageSeconds = _computedAverageDurationSeconds;
if (averageSeconds == null) return null;
return (averageSeconds / 60).round();
}
num? get _computedAverageDurationSeconds {
final values = daysWithData
.map((report) => report.duration?.minutes)
.whereType<int>()
.where((minutes) => minutes > 0)
.toList();
if (values.isEmpty) return null;
return (values.reduce((sum, minutes) => sum + minutes) / values.length)
.round();
if (values.isEmpty) {
return averageDurationSeconds;
}
return values.reduce((sum, minutes) => sum + minutes) * 60 / values.length;
}
int? get averageQualityScore {
if (averageScore != null) return averageScore!.round();
final values =
daysWithData.map((report) => report.qualityScore).whereType<int>();
if (values.isEmpty) return null;
return (values.reduce((sum, score) => sum + score) / values.length).round();
final average = _computedAverageScore;
return average?.round();
}
num? get _computedAverageScore {
final values = daysWithData
.map((report) => report.qualityScore)
.whereType<int>()
.toList();
if (values.isEmpty) return averageScore;
return values.reduce((sum, score) => sum + score) / values.length;
}
SleepQualityLevel get averageQualityLevel =>
sleepQualityLevelFromScore(averageQualityScore);
double? get durationComparisonPercent => _comparisonPercent(
averageDurationSeconds,
_computedAverageDurationSeconds,
previousAverageDurationSeconds,
);
double? get scoreComparisonPercent =>
_comparisonPercent(averageScore, previousAverageScore);
_comparisonPercent(_computedAverageScore, previousAverageScore);
SleepHeartRateSummary get heartRate {
final dailySummaries = daysWithData
... ... @@ -354,36 +365,47 @@ class MonthlySleepReport {
averageScore != null;
int? get averageDurationMinutes {
if (averageDurationSeconds != null) {
return (averageDurationSeconds! / 60).round();
}
final averageSeconds = _computedAverageDurationSeconds;
if (averageSeconds == null) return null;
return (averageSeconds / 60).round();
}
num? get _computedAverageDurationSeconds {
final values = daysWithData
.map((report) => report.duration?.minutes)
.whereType<int>()
.where((minutes) => minutes > 0)
.toList();
if (values.isEmpty) return null;
return (values.reduce((sum, minutes) => sum + minutes) / values.length)
.round();
if (values.isEmpty) {
return averageDurationSeconds;
}
return values.reduce((sum, minutes) => sum + minutes) * 60 / values.length;
}
int? get averageQualityScore {
if (averageScore != null) return averageScore!.round();
final values =
daysWithData.map((report) => report.qualityScore).whereType<int>();
if (values.isEmpty) return null;
return (values.reduce((sum, score) => sum + score) / values.length).round();
final average = _computedAverageScore;
return average?.round();
}
num? get _computedAverageScore {
final values = daysWithData
.map((report) => report.qualityScore)
.whereType<int>()
.toList();
if (values.isEmpty) return averageScore;
return values.reduce((sum, score) => sum + score) / values.length;
}
SleepQualityLevel get averageQualityLevel =>
sleepQualityLevelFromScore(averageQualityScore);
double? get durationComparisonPercent => _comparisonPercent(
averageDurationSeconds,
_computedAverageDurationSeconds,
previousAverageDurationSeconds,
);
double? get scoreComparisonPercent =>
_comparisonPercent(averageScore, previousAverageScore);
_comparisonPercent(_computedAverageScore, previousAverageScore);
factory MonthlySleepReport.empty(DateTime monthStart) {
final start = DateTime(monthStart.year, monthStart.month);
... ...
... ... @@ -132,6 +132,7 @@ class _DailyReportContent extends StatelessWidget {
),
const SizedBox(height: 12),
SleepHeartRateCard(
date: report?.date,
summary: report?.heartRate ?? const SleepHeartRateSummary(),
),
],
... ...
import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
... ... @@ -11,18 +9,19 @@ import '../models/sleep_report_models.dart';
class SleepHeartRateCard extends StatelessWidget {
const SleepHeartRateCard({
super.key,
this.date,
required this.summary,
});
final DateTime? date;
final SleepHeartRateSummary summary;
static const _h1 = Color(0xFF0F0F11);
static const _h3 = Color(0xFFB0B0B6);
static const _grid = Color(0xFFF3F3F3);
static const _red = Color(0xFFFF5279);
static const _orange = Color(0xFFFF9A6E);
static const _blue = Color(0xFF7B9BFB);
static const _green = Color(0xFF3BD49D);
static const _chartLeftInset = 0.0;
static const _chartRightInset = 20.0;
@override
Widget build(BuildContext context) {
... ... @@ -31,7 +30,7 @@ class SleepHeartRateCard extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
... ... @@ -50,31 +49,55 @@ class SleepHeartRateCard extends StatelessWidget {
_MetricRow(summary: summary),
const SizedBox(height: 18),
Expanded(
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
LayoutBuilder(
builder: (context, constraints) => LineChart(
_chartData(constraints.maxWidth),
),
),
Positioned.fill(
bottom: 20,
child: _HeartRateAxisLabels(
range: _heartRateYAxisRange,
),
),
if (!summary.hasData)
Text(
context.l10n.reportWaitingForData,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
],
child: Builder(
builder: (context) {
final xAxis = _heartRateXAxis;
final chartPoints = _chartPoints(xAxis);
final hasChartData = chartPoints.isNotEmpty;
return Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
if (hasChartData) ...[
Positioned.fill(
bottom: 20,
child: LayoutBuilder(
builder: (context, constraints) => LineChart(
_chartData(
xAxis: xAxis,
chartPoints: chartPoints,
chartWidth: constraints.maxWidth,
),
),
),
),
Positioned.fill(
bottom: 20,
child: _HeartRateAxisLabels(
range: _HeartRateYAxisRange.fromPoints(chartPoints),
),
),
Positioned.fill(
child: _HeartRateTimeLabels(axis: xAxis),
),
] else
Positioned.fill(
bottom: 20,
child: Center(
child: Text(
context.l10n.reportWaitingForData,
style: const TextStyle(
color: Color(0xFFA1A0A5),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
),
),
],
);
},
),
),
],
... ... @@ -82,34 +105,35 @@ class SleepHeartRateCard extends StatelessWidget {
);
}
LineChartData _chartData(double chartWidth) {
final sleepStart = _sleepStart;
final sleepEnd = _sleepEnd;
final sleepPoints = _sleepPoints(sleepStart, sleepEnd);
final spots = sleepPoints
LineChartData _chartData({
required _HeartRateXAxis xAxis,
required List<SleepHeartRatePoint> chartPoints,
required double chartWidth,
}) {
final spots = chartPoints
.map(
(point) => FlSpot(
point.time.difference(sleepStart).inMinutes.toDouble(),
point.time.difference(xAxis.start).inMinutes.toDouble(),
point.bpm,
),
)
.toList();
final maxX = sleepEnd.difference(sleepStart).inMinutes.clamp(1, 1440);
const rightVisualInset = 20.0;
final availableWidth = chartWidth - rightVisualInset;
final maxX = xAxis.end.difference(xAxis.start).inMinutes.clamp(1, 1440);
final dataWidth = chartWidth - _chartLeftInset - _chartRightInset;
final leftXInset = dataWidth > 0 ? _chartLeftInset * maxX / dataWidth : 0.0;
final rightXInset =
availableWidth > 0 ? rightVisualInset * maxX / availableWidth : 0.0;
final yRange = _HeartRateYAxisRange.fromPoints(sleepPoints);
dataWidth > 0 ? _chartRightInset * maxX / dataWidth : 0.0;
final yRange = _HeartRateYAxisRange.fromPoints(chartPoints);
return LineChartData(
minX: 0,
minX: -leftXInset,
maxX: maxX + rightXInset,
minY: yRange.minY,
maxY: yRange.maxY,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 10,
horizontalInterval: _HeartRateYAxisRange.interval,
getDrawingHorizontalLine: (_) => const FlLine(
color: _grid,
strokeWidth: 1,
... ... @@ -125,6 +149,12 @@ class SleepHeartRateCard extends StatelessWidget {
strokeWidth: 1,
dashArray: const [2, 2],
),
HorizontalLine(
y: yRange.maxY,
color: _grid,
strokeWidth: 1,
dashArray: const [2, 2],
),
],
),
borderData: FlBorderData(show: false),
... ... @@ -136,28 +166,8 @@ class SleepHeartRateCard extends StatelessWidget {
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 20,
interval: maxX / 2,
getTitlesWidget: (value, meta) {
final label = _bottomLabel(
value: value,
sleepStart: sleepStart,
sleepEnd: sleepEnd,
);
if (label == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 3),
child: Text(
label,
style: const TextStyle(
color: _h3,
fontSize: 10,
fontWeight: FontWeight.w400,
),
),
);
},
showTitles: false,
reservedSize: 0,
),
),
),
... ... @@ -166,183 +176,107 @@ class SleepHeartRateCard extends StatelessWidget {
}
List<LineChartBarData> _lineSegments(List<FlSpot> spots) {
if (spots.length < 2) return const [];
final maxHr = summary.maxBpm?.toDouble();
final boundaries = <double>{
140,
160,
if (maxHr != null && maxHr > 0) ...[
maxHr * 0.6,
maxHr * 0.8,
maxHr * 0.9,
],
}.toList()
..sort();
final segments = <_HeartRateLineSegment>[];
Color? currentColor;
var currentSpots = <FlSpot>[];
for (var index = 0; index < spots.length - 1; index++) {
final splitSpots = _splitAtBoundaries(
spots[index],
spots[index + 1],
boundaries,
);
for (var splitIndex = 0;
splitIndex < splitSpots.length - 1;
splitIndex++) {
final from = splitSpots[splitIndex];
final to = splitSpots[splitIndex + 1];
final color = _zoneColor((from.y + to.y) / 2, maxHr);
if (color == currentColor && currentSpots.isNotEmpty) {
currentSpots.add(to);
continue;
}
if (currentColor != null && currentSpots.length > 1) {
segments.add(_HeartRateLineSegment(currentColor, currentSpots));
}
currentColor = color;
currentSpots = [from, to];
}
}
if (currentColor != null && currentSpots.length > 1) {
segments.add(_HeartRateLineSegment(currentColor, currentSpots));
}
if (spots.isEmpty) return const <LineChartBarData>[];
return [
for (final segment in segments)
LineChartBarData(
spots: segment.spots,
color: segment.color,
barWidth: 2,
isCurved: false,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
segment.color.withValues(alpha: 0.20),
segment.color.withValues(alpha: 0.02),
],
),
LineChartBarData(
spots: spots,
color: _red,
barWidth: spots.length > 1 ? 2 : 0,
isCurved: false,
dotData: FlDotData(show: spots.length == 1),
belowBarData: BarAreaData(
show: spots.length > 1,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_red.withValues(alpha: 0.20),
_red.withValues(alpha: 0.02),
],
),
),
),
];
}
List<FlSpot> _splitAtBoundaries(
FlSpot start,
FlSpot end,
List<double> boundaries,
) {
if (start.y == end.y) return [start, end];
final minY = math.min(start.y, end.y);
final maxY = math.max(start.y, end.y);
final crossings = <FlSpot>[];
for (final boundary in boundaries) {
if (boundary <= minY || boundary >= maxY) continue;
final progress = (boundary - start.y) / (end.y - start.y);
crossings.add(
FlSpot(
start.x + (end.x - start.x) * progress,
boundary,
),
_HeartRateXAxis get _heartRateXAxis {
final points = _visibleDatePoints;
if (points.isEmpty) {
final start = _chartDayStart;
return _HeartRateXAxis(
start: start,
end: start.add(const Duration(hours: 1)),
);
}
crossings.sort((a, b) => a.x.compareTo(b.x));
return [start, ...crossings, end];
}
Color _zoneColor(double currentHr, double? maxHr) {
if (currentHr >= 160) return _red;
if (currentHr >= 140) return _orange;
final zoneRatio = maxHr == null || maxHr <= 0 ? 0 : currentHr / maxHr;
if (zoneRatio >= 0.9) return _red;
if (zoneRatio >= 0.8) return _orange;
if (zoneRatio >= 0.6) return _blue;
return _green;
}
_HeartRateYAxisRange get _heartRateYAxisRange {
final sleepStart = _sleepStart;
final sleepEnd = _sleepEnd;
return _HeartRateYAxisRange.fromPoints(
_sleepPoints(sleepStart, sleepEnd),
final start = points.first.time;
final end = points.last.time.isAfter(start)
? points.last.time
: start.add(const Duration(minutes: 1));
return _HeartRateXAxis(
start: start,
end: end,
);
}
DateTime get _sleepStart {
final sleepStart = summary.sleepStart;
final sleepEnd = summary.sleepEnd;
if (sleepStart != null &&
(summary.points.isEmpty ||
sleepEnd == null ||
summary.points.any((point) =>
!point.time.isBefore(sleepStart) &&
!point.time.isAfter(sleepEnd)))) {
return sleepStart;
DateTime get _chartDayStart {
final selectedDate = date;
if (selectedDate != null && _hasPointOnDate(selectedDate)) {
return DateTime(selectedDate.year, selectedDate.month, selectedDate.day);
}
if (summary.points.isNotEmpty) return summary.points.first.time;
return DateTime(0, 1, 1, 22);
final source = summary.points.isEmpty
? selectedDate ?? summary.sleepStart
: summary.points.first.time;
final day = source ?? DateTime.now();
return DateTime(day.year, day.month, day.day);
}
DateTime get _sleepEnd {
final sleepStart = summary.sleepStart;
final sleepEnd = summary.sleepEnd;
if (sleepEnd != null &&
(summary.points.isEmpty ||
sleepStart == null ||
summary.points.any((point) =>
!point.time.isBefore(sleepStart) &&
!point.time.isAfter(sleepEnd)))) {
return sleepEnd;
}
if (summary.points.isNotEmpty) return summary.points.last.time;
return DateTime(0, 1, 2, 7);
bool _hasPointOnDate(DateTime date) {
final dayStart = DateTime(date.year, date.month, date.day);
final nextDay = dayStart.add(const Duration(days: 1));
return summary.points.any(
(point) => !point.time.isBefore(dayStart) && point.time.isBefore(nextDay),
);
}
List<SleepHeartRatePoint> _sleepPoints(
DateTime sleepStart,
DateTime sleepEnd,
) {
List<SleepHeartRatePoint> _chartPoints(_HeartRateXAxis axis) {
return summary.points
.where((point) =>
!point.time.isBefore(sleepStart) && !point.time.isAfter(sleepEnd))
!point.time.isBefore(axis.start) && !point.time.isAfter(axis.end))
.toList();
}
String? _bottomLabel({
required double value,
required DateTime sleepStart,
required DateTime sleepEnd,
}) {
final maxX = sleepEnd.difference(sleepStart).inMinutes.toDouble();
final midX = maxX / 2;
final tolerance = maxX <= 2 ? 0.5 : 1.0;
if ((value - 0).abs() <= tolerance) {
return DateFormat('HH:mm').format(sleepStart);
}
if ((value - midX).abs() <= tolerance) {
return DateFormat('HH:mm').format(sleepStart.add(
Duration(minutes: midX.round()),
));
}
if ((value - maxX).abs() <= tolerance) {
return DateFormat('HH:mm').format(sleepEnd);
}
return null;
List<SleepHeartRatePoint> get _visibleDatePoints {
if (summary.points.isEmpty) return const <SleepHeartRatePoint>[];
final selectedDate = date;
final points = selectedDate == null
? summary.points
: summary.points.where((point) {
final dayStart = DateTime(
selectedDate.year,
selectedDate.month,
selectedDate.day,
);
final nextDay = dayStart.add(const Duration(days: 1));
return !point.time.isBefore(dayStart) &&
point.time.isBefore(nextDay);
}).toList();
final result = points.isEmpty ? summary.points : points;
return [...result]..sort((a, b) => a.time.compareTo(b.time));
}
}
class _HeartRateLineSegment {
const _HeartRateLineSegment(this.color, this.spots);
class _HeartRateXAxis {
const _HeartRateXAxis({
required this.start,
required this.end,
});
final Color color;
final List<FlSpot> spots;
final DateTime start;
final DateTime end;
}
class _HeartRateAxisLabels extends StatelessWidget {
... ... @@ -355,16 +289,18 @@ class _HeartRateAxisLabels extends StatelessWidget {
return IgnorePointer(
child: LayoutBuilder(
builder: (context, constraints) {
final steps = ((range.maxY - range.minY) / 10).round();
final labels = range.labels;
return Stack(
clipBehavior: Clip.none,
children: [
for (var index = 0; index <= steps; index++)
for (final label in labels)
Positioned(
top: constraints.maxHeight * (1 - index / steps) - 16,
top: constraints.maxHeight *
(1 - (label - range.minY) / range.span) -
16,
right: 0,
child: Text(
'${range.minY.toInt() + index * 10}',
'${label.toInt()}',
style: const TextStyle(
color: SleepHeartRateCard._h3,
fontSize: 10,
... ... @@ -381,15 +317,121 @@ class _HeartRateAxisLabels extends StatelessWidget {
}
}
class _HeartRateTimeLabels extends StatelessWidget {
const _HeartRateTimeLabels({
required this.axis,
});
final _HeartRateXAxis axis;
@override
Widget build(BuildContext context) {
final totalMinutes = axis.end.difference(axis.start).inMinutes;
final ticks = <DateTime>[
axis.start,
axis.start.add(Duration(minutes: totalMinutes ~/ 2)),
axis.end,
];
return IgnorePointer(
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
const labelWidth = 34.0;
return Stack(
clipBehavior: Clip.none,
children: [
for (var index = 0; index < ticks.length; index++)
_timeLabel(ticks, index, width, labelWidth),
],
);
},
),
);
}
Widget _timeLabel(
List<DateTime> ticks,
int index,
double width,
double labelWidth,
) {
final tick = ticks[index];
final totalMinutes = axis.end.difference(axis.start).inMinutes;
final tickMinutes = tick.difference(axis.start).inMinutes;
final progress = totalMinutes <= 0 ? 0.0 : tickMinutes / totalMinutes;
final plotLeft = SleepHeartRateCard._chartLeftInset;
final plotRight = width - SleepHeartRateCard._chartRightInset;
final plotWidth =
(plotRight - plotLeft).clamp(0, double.infinity).toDouble();
final tickX = plotLeft + plotWidth * progress;
final left = tickX - labelWidth / 2;
return Positioned(
left: left,
bottom: 0,
width: labelWidth,
child: _TimeLabel(
_tickLabel(tick),
textAlign: TextAlign.center,
),
);
}
String _tickLabel(DateTime tick) {
return DateFormat('HH:mm').format(tick);
}
}
class _TimeLabel extends StatelessWidget {
const _TimeLabel(
this.text, {
required this.textAlign,
});
final String text;
final TextAlign textAlign;
@override
Widget build(BuildContext context) {
return Text(
text,
textAlign: textAlign,
style: const TextStyle(
color: SleepHeartRateCard._h3,
fontSize: 10,
fontWeight: FontWeight.w400,
height: 1.2,
),
);
}
}
class _HeartRateYAxisRange {
const _HeartRateYAxisRange({
required this.minY,
required this.maxY,
});
static const interval = 20.0;
final double minY;
final double maxY;
double get span => maxY - minY <= 0 ? interval : maxY - minY;
List<double> get labels {
final values = <double>[minY];
var value = minY + interval;
while (value < maxY) {
values.add(value);
value += interval;
}
if (values.last != maxY) values.add(maxY);
return values;
}
factory _HeartRateYAxisRange.fromPoints(List<SleepHeartRatePoint> points) {
if (points.isEmpty) {
return const _HeartRateYAxisRange(minY: 50, maxY: 80);
... ... @@ -398,13 +440,13 @@ class _HeartRateYAxisRange {
final values = points.map((point) => point.bpm);
final minBpm = values.reduce((min, bpm) => bpm < min ? bpm : min);
final maxBpm = values.reduce((max, bpm) => bpm > max ? bpm : max);
var minY = (minBpm ~/ 10) * 10;
var maxY = ((maxBpm + 9) ~/ 10) * 10;
if (minY == maxY) maxY += 10;
final minY = minBpm;
var maxY = (maxBpm / 5).ceil() * 5.0;
if (minY == maxY) maxY += interval;
return _HeartRateYAxisRange(
minY: minY.toDouble(),
maxY: maxY.toDouble(),
minY: minY,
maxY: maxY,
);
}
}
... ...
... ... @@ -462,9 +462,16 @@ class _DurationChartState extends State<_DurationChart> {
}
double get _maxY {
final target = _targetDurationHours;
if (target == null || target <= 8) return 8;
return (target / 2).ceil() * 2;
final values = <double>[
8,
for (final day in widget.days)
if (day.duration != null) day.duration!.minutes / 60,
if (widget.averageDurationMinutes != null)
widget.averageDurationMinutes! / 60,
if (_targetDurationHours case final target?) target,
];
final maxValue = values.reduce((max, value) => value > max ? value : max);
return (maxValue / 2).ceil() * 2;
}
@override
... ... @@ -498,7 +505,11 @@ class _DurationChartState extends State<_DurationChart> {
dashed: true,
),
],
child: BarChart(_chartData()),
child: LayoutBuilder(
builder: (context, constraints) => BarChart(
_chartData(constraints.maxWidth),
),
),
),
if (_selectedOffset != null)
Positioned.fill(
... ... @@ -515,11 +526,16 @@ class _DurationChartState extends State<_DurationChart> {
);
}
BarChartData _chartData() {
BarChartData _chartData(double plotWidth) {
return BarChartData(
minY: 0,
maxY: _maxY,
alignment: BarChartAlignment.spaceAround,
alignment: BarChartAlignment.spaceBetween,
groupsSpace: _barGroupsSpace(
plotWidth,
widget.days.length,
widget.compact,
),
barTouchData: _barTouchData(
days: widget.days,
dataBuilder: _ChartSummaryData.duration,
... ... @@ -528,7 +544,7 @@ class _DurationChartState extends State<_DurationChart> {
),
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
titlesData: _titlesData(
titlesData: _SleepTrendXAxis.titlesData(
days: widget.days,
compact: widget.compact,
),
... ... @@ -540,7 +556,7 @@ class _DurationChartState extends State<_DurationChart> {
BarChartRodData(
toY: ((widget.days[i].duration?.minutes ?? 0) / 60)
.clamp(0, _maxY),
width: widget.compact ? 4 : 16,
width: _barWidth(widget.compact),
color: SleepWeekReportView.brand,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(8),
... ... @@ -595,7 +611,11 @@ class _QualityChartState extends State<_QualityChart> {
color: SleepWeekReportView.qualityAverageLine,
),
],
child: BarChart(_chartData()),
child: LayoutBuilder(
builder: (context, constraints) => BarChart(
_chartData(constraints.maxWidth),
),
),
),
if (_selectedOffset != null)
Positioned.fill(
... ... @@ -612,11 +632,16 @@ class _QualityChartState extends State<_QualityChart> {
);
}
BarChartData _chartData() {
BarChartData _chartData(double plotWidth) {
return BarChartData(
minY: 0,
maxY: 100,
alignment: BarChartAlignment.spaceAround,
alignment: BarChartAlignment.spaceBetween,
groupsSpace: _barGroupsSpace(
plotWidth,
widget.days.length,
widget.compact,
),
barTouchData: _barTouchData(
days: widget.days,
dataBuilder: _ChartSummaryData.quality,
... ... @@ -625,7 +650,7 @@ class _QualityChartState extends State<_QualityChart> {
),
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
titlesData: _titlesData(
titlesData: _SleepTrendXAxis.titlesData(
days: widget.days,
compact: widget.compact,
),
... ... @@ -636,7 +661,7 @@ class _QualityChartState extends State<_QualityChart> {
barRods: [
BarChartRodData(
toY: (widget.days[i].qualityScore ?? 0).toDouble(),
width: widget.compact ? 4 : 16,
width: _barWidth(widget.compact),
color: SleepWeekReportView.blue,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(8),
... ... @@ -703,7 +728,7 @@ class _BedtimeChart extends StatelessWidget {
dataBuilder: _ChartSummaryData.bedtime,
),
),
titlesData: _titlesData(
titlesData: _SleepTrendXAxis.titlesData(
days: days,
compact: compact,
),
... ... @@ -737,6 +762,15 @@ class _BedtimeChart extends StatelessWidget {
}
}
double _barWidth(bool compact) => compact ? 4 : 16;
double _barGroupsSpace(double plotWidth, int count, bool compact) {
if (count <= 1) return 0;
final barWidth = _barWidth(compact);
final space = (plotWidth - barWidth * count) / (count - 1);
return space < 0 ? 0 : space;
}
class _BedtimeAxis {
const _BedtimeAxis({
required this.minHour,
... ... @@ -1145,7 +1179,8 @@ class _ChartPlotFrame extends StatelessWidget {
this.referenceLines = const [],
});
static const horizontalInset = 16.0;
static const horizontalInset = 20.0;
static const rightAxisWidth = 32.0;
static const bottomTitleHeight = 28.0;
final Widget child;
... ... @@ -1167,16 +1202,28 @@ class _ChartPlotFrame extends StatelessWidget {
minY: minY,
maxY: maxY,
interval: interval,
referenceLines: referenceLines,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: horizontalInset),
padding: const EdgeInsets.only(
left: horizontalInset,
right: rightAxisWidth,
),
child: child,
),
Positioned.fill(
bottom: bottomTitleHeight,
child: CustomPaint(
painter: _ReferenceLinePainter(
minY: minY,
maxY: maxY,
referenceLines: referenceLines,
),
),
),
Positioned.fill(
bottom: bottomTitleHeight,
child: _RightAxisLabels(
minY: minY,
maxY: maxY,
... ... @@ -1194,13 +1241,11 @@ class _HorizontalDashedGridPainter extends CustomPainter {
required this.minY,
required this.maxY,
required this.interval,
required this.referenceLines,
});
final double minY;
final double maxY;
final double interval;
final List<_ChartReferenceLine> referenceLines;
@override
void paint(Canvas canvas, Size size) {
... ... @@ -1219,6 +1264,29 @@ class _HorizontalDashedGridPainter extends CustomPainter {
);
}
}
}
@override
bool shouldRepaint(covariant _HorizontalDashedGridPainter oldDelegate) =>
oldDelegate.minY != minY ||
oldDelegate.maxY != maxY ||
oldDelegate.interval != interval;
}
class _ReferenceLinePainter extends CustomPainter {
const _ReferenceLinePainter({
required this.minY,
required this.maxY,
required this.referenceLines,
});
final double minY;
final double maxY;
final List<_ChartReferenceLine> referenceLines;
@override
void paint(Canvas canvas, Size size) {
if (maxY <= minY) return;
for (final line in referenceLines) {
if (line.value < minY || line.value > maxY) continue;
final y = size.height * (maxY - line.value) / (maxY - minY);
... ... @@ -1240,10 +1308,9 @@ class _HorizontalDashedGridPainter extends CustomPainter {
}
@override
bool shouldRepaint(covariant _HorizontalDashedGridPainter oldDelegate) =>
bool shouldRepaint(covariant _ReferenceLinePainter oldDelegate) =>
oldDelegate.minY != minY ||
oldDelegate.maxY != maxY ||
oldDelegate.interval != interval ||
oldDelegate.referenceLines != referenceLines;
}
... ... @@ -1272,15 +1339,20 @@ class _RightAxisLabels extends StatelessWidget {
for (var index = 0; index <= steps; index++)
Positioned(
top: constraints.maxHeight * (1 - index / steps) - 16,
right: _ChartPlotFrame.horizontalInset,
child: Text(
labelBuilder(minY + interval * index),
maxLines: 1,
style: const TextStyle(
color: SleepWeekReportView.h3,
fontSize: 10,
fontWeight: FontWeight.w400,
height: 1.2,
right: 0,
width: _ChartPlotFrame.rightAxisWidth,
child: Align(
alignment: Alignment.centerRight,
child: Text(
labelBuilder(minY + interval * index),
maxLines: 1,
textAlign: TextAlign.right,
style: const TextStyle(
color: SleepWeekReportView.h3,
fontSize: 10,
fontWeight: FontWeight.w400,
height: 1.2,
),
),
),
),
... ... @@ -1292,72 +1364,70 @@ class _RightAxisLabels extends StatelessWidget {
}
}
FlTitlesData _titlesData({
required List<SleepReport> days,
required bool compact,
}) {
return FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= days.length) {
return const SizedBox.shrink();
}
if (compact && !_shouldShowMonthAxisLabel(days[index].date)) {
return const SizedBox.shrink();
}
final day = days[index].date;
return Padding(
padding: const EdgeInsets.only(top: 4),
child: compact
? Text('${day.day}', style: _axisTextStyle)
: Column(
children: [
Text(
'${day.day}',
style: _axisTextStyle,
),
Text(
_weekdayLabel(day),
style: _axisTextStyle,
),
],
),
);
},
),
),
class _SleepTrendXAxis {
const _SleepTrendXAxis._();
static const reservedHeight = 28.0;
static const labelTopPadding = 4.0;
static const labelStyle = TextStyle(
color: SleepWeekReportView.h3,
fontSize: 10,
fontWeight: FontWeight.w400,
height: 1.1,
);
}
bool _shouldShowMonthAxisLabel(DateTime date) {
final dayNumber = date.day;
final monthEnd = DateTime(date.year, date.month + 1, 0).day;
return dayNumber == 1 ||
dayNumber == 5 ||
dayNumber == 10 ||
dayNumber == 15 ||
dayNumber == 20 ||
dayNumber == 25 ||
dayNumber == monthEnd;
}
static FlTitlesData titlesData({
required List<SleepReport> days,
required bool compact,
}) {
return FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: reservedHeight,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= days.length) {
return const SizedBox.shrink();
}
if (compact && !_shouldShowMonthAxisLabel(days[index].date)) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: labelTopPadding),
child: _label(days[index].date, compact),
);
},
),
),
);
}
const _axisTextStyle = TextStyle(
color: SleepWeekReportView.h3,
fontSize: 10,
fontWeight: FontWeight.w400,
height: 1.1,
);
static Widget _label(DateTime day, bool compact) {
if (compact) return Text('${day.day}', style: labelStyle);
return Column(
children: [
Text('${day.day}', style: labelStyle),
Text(reportWeekdayLabel(day.weekday), style: labelStyle),
],
);
}
String _weekdayLabel(DateTime date) {
return reportWeekdayLabel(date.weekday);
static bool _shouldShowMonthAxisLabel(DateTime date) {
final dayNumber = date.day;
final monthEnd = DateTime(date.year, date.month + 1, 0).day;
return dayNumber == 1 ||
dayNumber == 5 ||
dayNumber == 10 ||
dayNumber == 15 ||
dayNumber == 20 ||
dayNumber == 25 ||
dayNumber == monthEnd;
}
}
class _ExtremeMetric extends StatelessWidget {
... ... @@ -1488,7 +1558,7 @@ class _ExtremeMetric extends StatelessWidget {
),
const SizedBox(height: 3),
Text(
date == null ? '-' : _dateText(context, date!),
date == null ? '-月-日 周-' : _dateText(context, date!),
style: const TextStyle(
color: SleepWeekReportView.h2,
fontSize: 12,
... ...
import 'package:doublefeel_flutter/app/modules/friends/bindings/friend_home_binding.dart';
import 'package:doublefeel_flutter/app/modules/friends/views/friend_home_page.dart';
import 'package:doublefeel_flutter/core/config/app_environment_config.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import '../modules/bind_partner/bindings/bind_partner_binding.dart';
import '../modules/bind_partner/views/bind_partner_view.dart';
import '../modules/config/bindings/developer_options_binding.dart';
import '../modules/config/views/developer_options_view.dart';
import '../modules/config/views/token_login_view.dart';
import '../modules/config/views/user_information_view.dart';
import '../modules/devtools/bindings/route_list_binding.dart';
import '../modules/devtools/views/route_list_view.dart';
import '../modules/feedback/feedback_list/bindings/feedback_list_binding.dart';
import '../modules/feedback/feedback_list/views/feedback_list_view.dart';
... ... @@ -130,9 +134,23 @@ abstract final class AppPages {
binding: FriendTrendBinding(),
),
GetPage(
name: Routes.DEVELOPER_OPTIONS,
page: () => const DeveloperOptionsView(),
binding: DeveloperOptionsBinding(),
),
GetPage(
name: Routes.USER_INFORMATION,
page: () => const UserInformationView(),
),
GetPage(
name: Routes.TOKEN_LOGIN,
page: () => const TokenLoginView(),
binding: DeveloperOptionsBinding(),
),
GetPage(
name: Routes.ROUTE_LIST,
page: () => const RouteListView(),
binding: PurchaseBinding(),
binding: RouteListBinding(),
),
GetPage(
name: Routes.PURCHASE,
... ...
... ... @@ -6,6 +6,9 @@ abstract class Routes {
static const USER_ONBOARDING = _Paths.USER_ONBOARDING;
static const MEMBERSHIP_OFFER = _Paths.MEMBERSHIP_OFFER;
static const LOGIN = _Paths.LOGIN;
static const DEVELOPER_OPTIONS = _Paths.DEVELOPER_OPTIONS;
static const USER_INFORMATION = _Paths.USER_INFORMATION;
static const TOKEN_LOGIN = _Paths.TOKEN_LOGIN;
static const ROUTE_LIST = _Paths.ROUTE_LIST;
static const PURCHASE = _Paths.PURCHASE;
static const ADD_FRIEND = _Paths.ADD_FRIEND;
... ... @@ -29,6 +32,9 @@ abstract class _Paths {
static const USER_ONBOARDING = '/user-onboarding';
static const MEMBERSHIP_OFFER = '/membership-offer';
static const LOGIN = '/login';
static const DEVELOPER_OPTIONS = '/developer-options';
static const USER_INFORMATION = '/developer-options/user-information';
static const TOKEN_LOGIN = '/developer-options/token-login';
static const ROUTE_LIST = '/route-list';
static const PURCHASE = '/purchase';
static const ADD_FRIEND = '/add-friend';
... ...
import 'package:doublefeel_flutter/r.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
class PremiumBenefitListItem {
const PremiumBenefitListItem({required this.title, required this.iconAsset});
final String title;
final String iconAsset;
}
class PremiumBenefitListView extends StatelessWidget {
const PremiumBenefitListView({super.key, this.benefits});
final List<PremiumBenefitListItem>? benefits;
@override
Widget build(BuildContext context) {
final benefitItems = benefits ?? _defaultBenefits(context);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
for (final benefit in benefitItems)
_PremiumBenefitItemView(benefit: benefit),
],
),
);
}
List<PremiumBenefitListItem> _defaultBenefits(BuildContext context) => [
PremiumBenefitListItem(
title: context.l10n.purchaseBenefitRealtimeStress,
iconAsset: R.assetsImagesPremiumBenefitRealtimeStress,
),
PremiumBenefitListItem(
title: context.l10n.purchaseBenefitStressTrends,
iconAsset: R.assetsImagesPremiumBenefitStressTrends,
),
PremiumBenefitListItem(
title: context.l10n.purchaseBenefitActivityTrends,
iconAsset: R.assetsImagesPremiumBenefitActivityTrends,
),
PremiumBenefitListItem(
title: context.l10n.purchaseBenefitSleepReports,
iconAsset: R.assetsImagesPremiumBenefitSleepReports,
),
PremiumBenefitListItem(
title: context.l10n.purchaseBenefitHealthSync,
iconAsset: R.assetsImagesPremiumBenefitHealthSync,
),
PremiumBenefitListItem(
title: context.l10n.purchaseBenefitContactNotifications,
iconAsset: R.assetsImagesPremiumBenefitContactNotifications,
),
PremiumBenefitListItem(
title: context.l10n.purchaseBenefitCustomWatchFace,
iconAsset: R.assetsImagesPremiumBenefitCustomWatchFace,
),
PremiumBenefitListItem(
title: context.l10n.purchaseBenefitSleepAnalysis,
iconAsset: R.assetsImagesPremiumBenefitSleepAnalysis,
),
PremiumBenefitListItem(
title: context.l10n.purchaseBenefitFutureFeatures,
iconAsset: R.assetsImagesPremiumBenefitFutureFeatures,
),
];
}
class _PremiumBenefitItemView extends StatelessWidget {
const _PremiumBenefitItemView({required this.benefit});
static const _titleColor = Color(0xFF0F0F11);
final PremiumBenefitListItem benefit;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 34,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Image.asset(
benefit.iconAsset,
width: 16,
height: 16,
fit: BoxFit.contain,
),
const SizedBox(width: 8),
Expanded(
child: Text(
benefit.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: _titleColor,
fontSize: 13,
height: 1.2,
),
),
),
const SizedBox(width: 8),
Image.asset(
R.assetsImagesPremiumBenefitCheck,
width: 20,
height: 20,
fit: BoxFit.contain,
),
],
),
),
);
}
}
... ...
abstract final class IntentKeys {
static const String keyFrom = "from";
}
... ...
... ... @@ -7,5 +7,10 @@ abstract final class StorageConst {
static const String appSettingsPrefsName = 'app_settings';
static const String termsAgreedKey = 'terms_agreed';
static const String lastLoginMethodKey = 'last_login_method';
static const String appReviewPromptNextShowAtKey = 'app_review_prompt_next_show_at';
static const String appReviewPromptNextShowAtKey =
'app_review_prompt_next_show_at';
static const String homeMembershipOfferPromptDateKey =
'home_membership_offer_prompt_date';
static const String homeMembershipOfferPromptUserIdKey =
'home_membership_offer_prompt_user_id';
}
... ...
... ... @@ -74,5 +74,7 @@ class UserStateService {
bool get isBound =>
(_userPrefs.preferences.value.partnerUserInfo?.id ?? 0) > 0;
int get userId => _userPrefs.preferences.value.meUserInfo?.id ?? 0;
bool get isVip => _userPrefs.preferences.value.vipInfo?.isVip ?? false;
}
... ...
... ... @@ -63,4 +63,26 @@ class LocalStorage {
value.millisecondsSinceEpoch,
);
}
// ─── Home Membership Offer Prompt Storage ─────────────────────────────────
int? get homeMembershipOfferPromptDate =>
sharedPreferences.getInt(StorageConst.homeMembershipOfferPromptDateKey);
int? get homeMembershipOfferPromptUserId =>
sharedPreferences.getInt(StorageConst.homeMembershipOfferPromptUserIdKey);
Future<void> setHomeMembershipOfferPromptState({
required int date,
required int userId,
}) async {
await sharedPreferences.setInt(
StorageConst.homeMembershipOfferPromptDateKey,
date,
);
await sharedPreferences.setInt(
StorageConst.homeMembershipOfferPromptUserIdKey,
userId,
);
}
}
... ...
... ... @@ -30,6 +30,7 @@ class FriendItem {
this.createTime,
this.updateTime,
this.avatar,
this.friendNickname,
this.healthData,
});
... ... @@ -47,6 +48,7 @@ class FriendItem {
final int? createTime;
final int? updateTime;
final String? avatar;
final String? friendNickname;
final FriendHealthData? healthData;
bool get isShowInDial => showInDial == 1;
... ... @@ -62,6 +64,7 @@ class FriendItem {
createTime: json['create_time'] as int?,
updateTime: json['update_time'] as int?,
avatar: json['avatar'] as String?,
friendNickname: json['friend_nickname'] as String?,
healthData: json['health_data'] == null
? null
: FriendHealthData.fromJson(
... ... @@ -80,6 +83,7 @@ class FriendItem {
if (createTime != null) val['create_time'] = createTime;
if (updateTime != null) val['update_time'] = updateTime;
if (avatar != null) val['avatar'] = avatar;
if (friendNickname != null) val['friend_nickname'] = friendNickname;
if (healthData != null) val['health_data'] = healthData!.toJson();
return val;
}
... ...
... ... @@ -385,6 +385,7 @@
"friendsMe": "Me",
"friendsRemarkedDisplayName": "{remark} ({name})",
"friendsRemarkSuffix": " ({remark})",
"friendsUnknownFriend": "Unknown friend",
"friendsUpdatedAt": "Updated at {time}",
"friendsStepCount": "{count} steps",
"friendsStressAttention": "Stress alert",
... ...
... ... @@ -488,6 +488,7 @@
"@friendsRemarkedDisplayName": {"placeholders": {"remark": {"type": "String"}, "name": {"type": "String"}}},
"friendsRemarkSuffix": "({remark})",
"@friendsRemarkSuffix": {"placeholders": {"remark": {"type": "String"}}},
"friendsUnknownFriend": "未知好友",
"friendsUpdatedAt": "更新于{time}",
"@friendsUpdatedAt": {"placeholders": {"time": {"type": "String"}}},
"friendsStepCount": "{count}步",
... ...
... ... @@ -2409,6 +2409,12 @@ abstract class AppLocalizations {
/// **'({remark})'**
String friendsRemarkSuffix(String remark);
/// No description provided for @friendsUnknownFriend.
///
/// In zh, this message translates to:
/// **'未知好友'**
String get friendsUnknownFriend;
/// No description provided for @friendsUpdatedAt.
///
/// In zh, this message translates to:
... ...
... ... @@ -1327,6 +1327,9 @@ class AppLocalizationsEn extends AppLocalizations {
}
@override
String get friendsUnknownFriend => 'Unknown friend';
@override
String friendsUpdatedAt(String time) {
return 'Updated at $time';
}
... ...
... ... @@ -1264,6 +1264,9 @@ class AppLocalizationsZh extends AppLocalizations {
}
@override
String get friendsUnknownFriend => '未知好友';
@override
String friendsUpdatedAt(String time) {
return '更新于$time';
}
... ...
... ... @@ -25,6 +25,10 @@ class R {
'assets/images/common/ic_trend_pro.webp';
static final String assetsImagesBottomSloganDoubleFeel =
'assets/images/common/ic_bottom_slogan_double_feel.webp';
static final String assetsImagesAppReviewBadBg =
'assets/images/common/bg_app_review_bad.webp';
static final String assetsImagesAppReviewGoodBg =
'assets/images/common/bg_app_review_good.webp';
// friends
static final String assetsImagesFriendsFriendAddMeIllustration =
... ... @@ -35,6 +39,8 @@ class R {
'assets/images/friends/bg_friend_add_subtract.webp';
static final String assetsImagesFriendsFriendAddVectorIcon =
'assets/images/friends/ic_friend_add_vector.webp';
static final String assetsImagesFriendsListNoDataIcon =
'assets/images/friends/ic_friends_list_no_data.webp';
// my
static final String assetsImagesMyAvatarDefault =
... ... @@ -51,4 +57,28 @@ class R {
'assets/images/common/ic_health_stress_normal.webp';
static final String assetsImagesRealtimeStressExcellentIcon =
'assets/images/common/ic_health_stress_excellent.webp';
// premium
static final String assetsImagesPurchaseTopBg =
'assets/images/premium/bg_purchase_top.webp';
static final String assetsImagesPremiumBenefitRealtimeStress =
'assets/images/premium/ic_premium_benefit_realtime_stress.png';
static final String assetsImagesPremiumBenefitStressTrends =
'assets/images/premium/ic_premium_benefit_stress_trends.png';
static final String assetsImagesPremiumBenefitActivityTrends =
'assets/images/premium/ic_premium_benefit_activity_trends.png';
static final String assetsImagesPremiumBenefitSleepReports =
'assets/images/premium/ic_premium_benefit_sleep_reports.png';
static final String assetsImagesPremiumBenefitHealthSync =
'assets/images/premium/ic_premium_benefit_health_sync.png';
static final String assetsImagesPremiumBenefitContactNotifications =
'assets/images/premium/ic_premium_benefit_contact_notifications.png';
static final String assetsImagesPremiumBenefitCustomWatchFace =
'assets/images/premium/ic_premium_benefit_custom_watch_face.png';
static final String assetsImagesPremiumBenefitSleepAnalysis =
'assets/images/premium/ic_premium_benefit_sleep_analysis.png';
static final String assetsImagesPremiumBenefitFutureFeatures =
'assets/images/premium/ic_premium_benefit_future_features.png';
static final String assetsImagesPremiumBenefitCheck =
'assets/images/premium/ic_premium_benefit_check.png';
}
... ...
... ... @@ -88,3 +88,4 @@ flutter:
- assets/images/permission/
- assets/images/watch_theme/
- assets/images/user_onboarding/
- assets/images/premium/
... ...