feat(apps): refine login consent and calendar day/month UX
This commit is contained in:
@@ -130,6 +130,7 @@ class LoginCubit extends Cubit<LoginState> {
|
|||||||
|
|
||||||
final requestPhone = state.e164Phone;
|
final requestPhone = state.e164Phone;
|
||||||
emit(state.copyWith(isSendingCode: true, errorMessage: null));
|
emit(state.copyWith(isSendingCode: true, errorMessage: null));
|
||||||
|
_startResendCooldown();
|
||||||
try {
|
try {
|
||||||
await _repository.sendOtp(requestPhone);
|
await _repository.sendOtp(requestPhone);
|
||||||
if (isClosed) {
|
if (isClosed) {
|
||||||
@@ -146,7 +147,6 @@ class LoginCubit extends Cubit<LoginState> {
|
|||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_startResendCooldown();
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isClosed) {
|
if (isClosed) {
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleLogin() async {
|
Future<void> _handleLogin() async {
|
||||||
|
if (!_agreedToTerms) {
|
||||||
|
final confirmed = await _showAgreementDialog();
|
||||||
|
if (!confirmed || !mounted) return;
|
||||||
|
setState(() => _agreedToTerms = true);
|
||||||
|
}
|
||||||
|
|
||||||
final cubit = context.read<LoginCubit>();
|
final cubit = context.read<LoginCubit>();
|
||||||
cubit.phoneChanged(_phoneController.text);
|
cubit.phoneChanged(_phoneController.text);
|
||||||
cubit.codeChanged(_codeController.text);
|
cubit.codeChanged(_codeController.text);
|
||||||
@@ -70,12 +76,6 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleSendCode() async {
|
Future<void> _handleSendCode() async {
|
||||||
if (!_agreedToTerms) {
|
|
||||||
final confirmed = await _showAgreementDialog();
|
|
||||||
if (!confirmed || !mounted) return;
|
|
||||||
setState(() => _agreedToTerms = true);
|
|
||||||
}
|
|
||||||
|
|
||||||
final cubit = context.read<LoginCubit>();
|
final cubit = context.read<LoginCubit>();
|
||||||
cubit.phoneChanged(_phoneController.text);
|
cubit.phoneChanged(_phoneController.text);
|
||||||
final sent = await cubit.sendCode();
|
final sent = await cubit.sendCode();
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import 'package:social_app/core/api/i_api_client.dart';
|
import 'package:social_app/core/api/i_api_client.dart';
|
||||||
|
import 'package:social_app/core/cache/cache_invalidator.dart';
|
||||||
|
import 'package:social_app/core/di/injection.dart';
|
||||||
|
|
||||||
import '../calendar_api.dart';
|
import '../calendar_api.dart';
|
||||||
import '../models/schedule_item_model.dart';
|
import '../models/schedule_item_model.dart';
|
||||||
@@ -37,11 +39,15 @@ class CalendarService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<ScheduleItemModel> addEvent(ScheduleItemModel event) async {
|
Future<ScheduleItemModel> addEvent(ScheduleItemModel event) async {
|
||||||
return _api.create(event);
|
final created = await _api.create(event);
|
||||||
|
_invalidateEventCache(created);
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ScheduleItemModel> updateEvent(ScheduleItemModel event) async {
|
Future<ScheduleItemModel> updateEvent(ScheduleItemModel event) async {
|
||||||
return _api.update(event);
|
final updated = await _api.update(event);
|
||||||
|
_invalidateEventCache(updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ScheduleItemModel?> archiveEvent(String id) async {
|
Future<ScheduleItemModel?> archiveEvent(String id) async {
|
||||||
@@ -49,10 +55,38 @@ class CalendarService {
|
|||||||
if (event == null) {
|
if (event == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return updateEvent(event.copyWith(status: ScheduleStatus.archived));
|
final updatedEvent = await updateEvent(
|
||||||
|
event.copyWith(status: ScheduleStatus.archived),
|
||||||
|
);
|
||||||
|
_invalidateEventCache(updatedEvent);
|
||||||
|
return updatedEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _invalidateEventCache(ScheduleItemModel event) {
|
||||||
|
try {
|
||||||
|
final invalidator = sl<CacheInvalidator>();
|
||||||
|
var current = DateTime(
|
||||||
|
event.startAt.year,
|
||||||
|
event.startAt.month,
|
||||||
|
event.startAt.day,
|
||||||
|
);
|
||||||
|
final end = DateTime(
|
||||||
|
event.endAt?.year ?? event.startAt.year,
|
||||||
|
event.endAt?.month ?? event.startAt.month,
|
||||||
|
event.endAt?.day ?? event.startAt.day,
|
||||||
|
);
|
||||||
|
while (!current.isAfter(end)) {
|
||||||
|
invalidator.invalidateCalendarDay(current);
|
||||||
|
current = current.add(const Duration(days: 1));
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteEvent(String id) async {
|
Future<void> deleteEvent(String id) async {
|
||||||
|
final event = await getEventById(id);
|
||||||
|
if (event != null) {
|
||||||
|
_invalidateEventCache(event);
|
||||||
|
}
|
||||||
await _api.delete(id);
|
await _api.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class DayEventLayoutEngine {
|
|||||||
required DayViewScale scale,
|
required DayViewScale scale,
|
||||||
required double eventAreaLeft,
|
required double eventAreaLeft,
|
||||||
required double eventAreaWidth,
|
required double eventAreaWidth,
|
||||||
|
required DateTime viewDate,
|
||||||
double columnGap = DayTimelineMetrics.eventColumnGap,
|
double columnGap = DayTimelineMetrics.eventColumnGap,
|
||||||
}) {
|
}) {
|
||||||
if (events.isEmpty || eventAreaWidth <= 0) {
|
if (events.isEmpty || eventAreaWidth <= 0) {
|
||||||
@@ -44,7 +45,8 @@ class DayEventLayoutEngine {
|
|||||||
|
|
||||||
final sorted =
|
final sorted =
|
||||||
events
|
events
|
||||||
.map(_EventSpan.fromEvent)
|
.map((e) => _EventSpan.fromEvent(e, viewDate))
|
||||||
|
.expand((spans) => spans)
|
||||||
.where((span) => span.endMinutes > span.startMinutes)
|
.where((span) => span.endMinutes > span.startMinutes)
|
||||||
.toList()
|
.toList()
|
||||||
..sort((a, b) {
|
..sort((a, b) {
|
||||||
@@ -149,19 +151,73 @@ class _EventSpan {
|
|||||||
required this.endMinutes,
|
required this.endMinutes,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory _EventSpan.fromEvent(ScheduleItemModel event) {
|
static List<_EventSpan> fromEvent(
|
||||||
final start = _minutesOfDay(event.startAt);
|
ScheduleItemModel event,
|
||||||
final end = event.endAt != null ? _minutesOfDay(event.endAt!) : start + 60;
|
DateTime viewDate,
|
||||||
final clampedStart = DayTimelineMetrics.clampMinuteOfDay(start);
|
) {
|
||||||
var clampedEnd = DayTimelineMetrics.clampMinuteOfDay(end);
|
final startAt = event.startAt;
|
||||||
if (clampedEnd <= clampedStart) {
|
final endAt = event.endAt;
|
||||||
clampedEnd = DayTimelineMetrics.clampMinuteOfDay(clampedStart + 1);
|
final viewDateOnly = DateTime(viewDate.year, viewDate.month, viewDate.day);
|
||||||
|
final startDateOnly = DateTime(startAt.year, startAt.month, startAt.day);
|
||||||
|
final endDateOnly = endAt != null
|
||||||
|
? DateTime(endAt.year, endAt.month, endAt.day)
|
||||||
|
: startDateOnly;
|
||||||
|
|
||||||
|
final startMinOfDay = _minutesOfDay(startAt);
|
||||||
|
final endMinOfDay = endAt != null
|
||||||
|
? _minutesOfDay(endAt)
|
||||||
|
: startMinOfDay + 60;
|
||||||
|
|
||||||
|
if (endDateOnly.isAfter(startDateOnly)) {
|
||||||
|
if (viewDateOnly.isAtSameMomentAs(startDateOnly)) {
|
||||||
|
final clampedStart = DayTimelineMetrics.clampMinuteOfDay(startMinOfDay);
|
||||||
|
final clampedEnd = DayTimelineMetrics.minutesInDay;
|
||||||
|
if (clampedEnd > clampedStart) {
|
||||||
|
return [
|
||||||
|
_EventSpan(
|
||||||
|
event: event,
|
||||||
|
startMinutes: clampedStart,
|
||||||
|
endMinutes: clampedEnd,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else if (viewDateOnly.isAtSameMomentAs(endDateOnly)) {
|
||||||
|
final clampedStart = 0;
|
||||||
|
final clampedEnd = DayTimelineMetrics.clampMinuteOfDay(endMinOfDay);
|
||||||
|
if (clampedEnd > clampedStart) {
|
||||||
|
return [
|
||||||
|
_EventSpan(
|
||||||
|
event: event,
|
||||||
|
startMinutes: clampedStart,
|
||||||
|
endMinutes: clampedEnd,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else if (viewDateOnly.isAfter(startDateOnly) &&
|
||||||
|
viewDateOnly.isBefore(endDateOnly)) {
|
||||||
|
return [
|
||||||
|
_EventSpan(
|
||||||
|
event: event,
|
||||||
|
startMinutes: 0,
|
||||||
|
endMinutes: DayTimelineMetrics.minutesInDay,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
}
|
}
|
||||||
return _EventSpan(
|
|
||||||
event: event,
|
final clampedStart = DayTimelineMetrics.clampMinuteOfDay(startMinOfDay);
|
||||||
startMinutes: clampedStart,
|
var clampedEnd = DayTimelineMetrics.clampMinuteOfDay(endMinOfDay);
|
||||||
endMinutes: clampedEnd,
|
if (clampedEnd <= clampedStart) {
|
||||||
);
|
clampedEnd = clampedStart + 1;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
_EventSpan(
|
||||||
|
event: event,
|
||||||
|
startMinutes: clampedStart,
|
||||||
|
endMinutes: clampedEnd,
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
class DayViewScale {
|
class DayViewScale {
|
||||||
static const double defaultHourHeight = 34.0;
|
static const double defaultHourHeight = 34.0;
|
||||||
static const double minHourHeight = 17.0;
|
static const double minHourHeight = 34.0;
|
||||||
static const double maxHourHeight = 68.0;
|
static const double maxHourHeight = 68.0;
|
||||||
|
|
||||||
final double hourHeight;
|
final double hourHeight;
|
||||||
@@ -8,7 +8,7 @@ class DayViewScale {
|
|||||||
const DayViewScale({required this.hourHeight});
|
const DayViewScale({required this.hourHeight});
|
||||||
|
|
||||||
factory DayViewScale.defaultScale() {
|
factory DayViewScale.defaultScale() {
|
||||||
return const DayViewScale(hourHeight: defaultHourHeight);
|
return const DayViewScale(hourHeight: minHourHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
DayViewScale copyWith({double? hourHeight}) {
|
DayViewScale copyWith({double? hourHeight}) {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
|||||||
final DayEventLayoutEngine _layoutEngine = const DayEventLayoutEngine();
|
final DayEventLayoutEngine _layoutEngine = const DayEventLayoutEngine();
|
||||||
final Map<int, Offset> _activePointers = {};
|
final Map<int, Offset> _activePointers = {};
|
||||||
final ScrollController _dayStripController = ScrollController();
|
final ScrollController _dayStripController = ScrollController();
|
||||||
|
final ScrollController _timelineController = ScrollController();
|
||||||
|
|
||||||
DayViewScale _scale = DayViewScale.defaultScale();
|
DayViewScale _scale = DayViewScale.defaultScale();
|
||||||
DayViewScale _pinchStartScale = DayViewScale.defaultScale();
|
DayViewScale _pinchStartScale = DayViewScale.defaultScale();
|
||||||
@@ -67,6 +68,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
|||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_scrollToSelectedDate();
|
_scrollToSelectedDate();
|
||||||
|
_scrollTimelineToNow();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +93,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_dayStripController.dispose();
|
_dayStripController.dispose();
|
||||||
|
_timelineController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +128,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
|||||||
onPointerCancel: _handlePointerCancel,
|
onPointerCancel: _handlePointerCancel,
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
controller: _timelineController,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
left: AppSpacing.lg,
|
left: AppSpacing.lg,
|
||||||
@@ -160,6 +164,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
|||||||
_calendarManager.setSelectedDate(today);
|
_calendarManager.setSelectedDate(today);
|
||||||
_updateMonthDates();
|
_updateMonthDates();
|
||||||
_scrollToSelectedDate(animate: true);
|
_scrollToSelectedDate(animate: true);
|
||||||
|
_scrollTimelineToNow(animate: true);
|
||||||
_loadEvents();
|
_loadEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +199,10 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
|||||||
if ((nextScale.hourHeight - _scale.hourHeight).abs() < 0.1) {
|
if ((nextScale.hourHeight - _scale.hourHeight).abs() < 0.1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (nextScale.hourHeight < _scale.hourHeight &&
|
||||||
|
_scale.hourHeight <= DayViewScale.minHourHeight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_scale = nextScale;
|
_scale = nextScale;
|
||||||
});
|
});
|
||||||
@@ -410,6 +419,29 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
|||||||
_dayStripController.jumpTo(offset);
|
_dayStripController.jumpTo(offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _scrollTimelineToNow({bool animate = false}) {
|
||||||
|
if (!_timelineController.hasClients) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final now = DateTime.now();
|
||||||
|
final minuteOfDay = now.hour * 60 + now.minute;
|
||||||
|
final targetY = _scale.pixelsForMinutes(minuteOfDay);
|
||||||
|
final viewportHeight = _timelineController.position.viewportDimension;
|
||||||
|
final offset = targetY - (viewportHeight / 3);
|
||||||
|
final max = _timelineController.position.maxScrollExtent;
|
||||||
|
final clampedOffset = offset.clamp(0.0, max);
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
_timelineController.animateTo(
|
||||||
|
clampedOffset,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_timelineController.jumpTo(clampedOffset);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildDayItem(DateTime date, bool isSelected, bool isWeekend) {
|
Widget _buildDayItem(DateTime date, bool isSelected, bool isWeekend) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
@@ -465,6 +497,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
|||||||
scale: _scale,
|
scale: _scale,
|
||||||
eventAreaLeft: eventAreaLeft,
|
eventAreaLeft: eventAreaLeft,
|
||||||
eventAreaWidth: eventAreaWidth,
|
eventAreaWidth: eventAreaWidth,
|
||||||
|
viewDate: _selectedDate,
|
||||||
);
|
);
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
@@ -521,19 +554,17 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
|||||||
}) {
|
}) {
|
||||||
final minute = hour * DayTimelineMetrics.minutesInHour;
|
final minute = hour * DayTimelineMetrics.minutesInHour;
|
||||||
final y = _scale.pixelsForMinutes(minute);
|
final y = _scale.pixelsForMinutes(minute);
|
||||||
final isDisabled = hour == DayTimelineMetrics.hoursInDay;
|
final isLastHour = hour == DayTimelineMetrics.hoursInDay;
|
||||||
final labelTop = (y - 7).clamp(0.0, boardHeight - 14);
|
final adjustedY = isLastHour ? boardHeight - 1 : y;
|
||||||
|
final labelTop = (adjustedY - 7).clamp(0.0, boardHeight - 14);
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned(
|
Positioned(
|
||||||
top: y,
|
top: adjustedY,
|
||||||
left: eventAreaLeft,
|
left: eventAreaLeft,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: Container(
|
child: Container(height: 1, color: AppColors.border),
|
||||||
height: 1,
|
|
||||||
color: isDisabled ? AppColors.blue50 : AppColors.border,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: labelTop,
|
top: labelTop,
|
||||||
@@ -545,7 +576,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: isDisabled ? AppColors.slate300 : AppColors.slate400,
|
color: AppColors.slate400,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -609,10 +640,16 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
|||||||
required DayEventLayout layout,
|
required DayEventLayout layout,
|
||||||
required double boardHeight,
|
required double boardHeight,
|
||||||
}) {
|
}) {
|
||||||
final eventColor = resolveEventColor(
|
final isArchived = layout.event.status == ScheduleStatus.archived;
|
||||||
status: layout.event.status,
|
Color eventColor;
|
||||||
colorHex: layout.event.metadata?.color,
|
if (isArchived) {
|
||||||
);
|
eventColor = AppColors.slate400;
|
||||||
|
} else {
|
||||||
|
eventColor = resolveEventColor(
|
||||||
|
status: layout.event.status,
|
||||||
|
colorHex: layout.event.metadata?.color,
|
||||||
|
);
|
||||||
|
}
|
||||||
final isCompact = layout.visualHeight < 20;
|
final isCompact = layout.visualHeight < 20;
|
||||||
final tapHeight = layout.visualHeight < _minEventTapHeight
|
final tapHeight = layout.visualHeight < _minEventTapHeight
|
||||||
? _minEventTapHeight
|
? _minEventTapHeight
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
|
|||||||
const SizedBox(height: AppSpacing.xs),
|
const SizedBox(height: AppSpacing.xs),
|
||||||
Text(
|
Text(
|
||||||
timeRange,
|
timeRange,
|
||||||
|
maxLines: 2,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -507,12 +508,21 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _formatRangeLabel(DateTime startAt, DateTime? endAt) {
|
String _formatRangeLabel(DateTime startAt, DateTime? endAt) {
|
||||||
final dateLabel =
|
final startLabel =
|
||||||
'${startAt.month}月${startAt.day}日 ${_getWeekday(startAt.weekday)}';
|
'${startAt.month}月${startAt.day}日 ${_getWeekday(startAt.weekday)} ${_formatTime(startAt)}';
|
||||||
if (endAt == null) {
|
if (endAt == null) {
|
||||||
return '$dateLabel ${_formatTime(startAt)}';
|
return startLabel;
|
||||||
}
|
}
|
||||||
return '$dateLabel ${_formatTime(startAt)} - ${_formatTime(endAt)}';
|
final isSameDay =
|
||||||
|
startAt.year == endAt.year &&
|
||||||
|
startAt.month == endAt.month &&
|
||||||
|
startAt.day == endAt.day;
|
||||||
|
if (isSameDay) {
|
||||||
|
return '$startLabel - ${_formatTime(endAt)}';
|
||||||
|
}
|
||||||
|
final endLabel =
|
||||||
|
'${endAt.month}月${endAt.day}日 ${_getWeekday(endAt.weekday)} ${_formatTime(endAt)}';
|
||||||
|
return '开始: $startLabel\n结束: $endLabel';
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStatusBadge(ScheduleStatus status) {
|
Widget _buildStatusBadge(ScheduleStatus status) {
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeader() {
|
Widget _buildHeader() {
|
||||||
|
final today = DateTime.now();
|
||||||
|
final isNotToday = !isSameDay(_selectedDate, today);
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 76,
|
height: 76,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -148,6 +151,31 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
if (isNotToday)
|
||||||
|
AppPressable(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||||
|
onTap: _goToToday,
|
||||||
|
child: Container(
|
||||||
|
height: 36,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.messageBtnWrap,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||||
|
border: Border.all(color: AppColors.messageBtnBorder),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Text(
|
||||||
|
'今天',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.slate700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isNotToday) const SizedBox(width: 8),
|
||||||
AppPressable(
|
AppPressable(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
@@ -181,6 +209,20 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _goToToday() {
|
||||||
|
final today = DateTime.now();
|
||||||
|
final targetMonth = DateTime(today.year, today.month, 1);
|
||||||
|
setState(() {
|
||||||
|
_selectedDate = today;
|
||||||
|
if (_currentMonth.year != targetMonth.year ||
|
||||||
|
_currentMonth.month != targetMonth.month) {
|
||||||
|
_currentMonth = targetMonth;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_calendarManager.setSelectedDate(today);
|
||||||
|
_loadMonthEvents(forceRefresh: true);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildMonthContent() {
|
Widget _buildMonthContent() {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -280,9 +322,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
|||||||
'${AppRoutes.calendarDayWeek}?date=${formatYmd(date)}',
|
'${AppRoutes.calendarDayWeek}?date=${formatYmd(date)}',
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: AnimatedContainer(
|
child: Container(
|
||||||
duration: const Duration(milliseconds: 140),
|
|
||||||
curve: Curves.easeOut,
|
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class BottomDock extends StatelessWidget {
|
|||||||
left: AppSpacing.xl,
|
left: AppSpacing.xl,
|
||||||
right: AppSpacing.xl,
|
right: AppSpacing.xl,
|
||||||
top: AppSpacing.md,
|
top: AppSpacing.md,
|
||||||
bottom: AppSpacing.lg,
|
bottom: AppSpacing.sm,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
4. 启动体验采用「本地优先 + 后台静默刷新」策略,减少进入 App 的重复请求。
|
4. 启动体验采用「本地优先 + 后台静默刷新」策略,减少进入 App 的重复请求。
|
||||||
5. 数据只在必要时刷新:手动下拉、写操作失效、生命周期关键点、缓存策略命中。
|
5. 数据只在必要时刷新:手动下拉、写操作失效、生命周期关键点、缓存策略命中。
|
||||||
6. 主页按钮语义固定为“回主页”,不再变成“返回上一页”。
|
6. 主页按钮语义固定为“回主页”,不再变成“返回上一页”。
|
||||||
|
7. 一级页面唯一为 Home,日历日/月视图、待办、设置均为二级页面;二级页面侧滑只允许返回一级页面,不允许直接退出 App。
|
||||||
|
8. App 退出入口仅存在于一级页面(Home)。
|
||||||
|
|
||||||
### 2.2 非目标
|
### 2.2 非目标
|
||||||
|
|
||||||
@@ -40,14 +42,20 @@
|
|||||||
|
|
||||||
### 4.1 导航分层
|
### 4.1 导航分层
|
||||||
|
|
||||||
采用两级导航:
|
采用分级导航:
|
||||||
|
|
||||||
1. 一级(主容器):`StatefulShellRoute.indexedStack`
|
1. 一级页面(唯一):Home
|
||||||
- 分支:Home / Calendar / Todo
|
- 仅 Home 允许触发系统退出路径。
|
||||||
- 作用:保活分支页面,避免 tab 切换重建。
|
2. 二级页面(主业务入口)
|
||||||
2. 二级(分支内部)
|
- Calendar Day/Month
|
||||||
- Calendar 分支:管理 month/day 主视图切换 + event detail/edit/share 子路由。
|
- Todo List(Quadrants)
|
||||||
- Todo 分支:管理 list/detail/edit 子路由。
|
- Settings
|
||||||
|
- 规则:二级页面的系统返回/侧滑返回统一回 Home,不允许直接退出 App。
|
||||||
|
3. 三级页面(细节页)
|
||||||
|
- Calendar event detail/edit/share
|
||||||
|
- Todo detail/edit
|
||||||
|
- Settings 子页面(account/profile edit 等)
|
||||||
|
- 规则:三级页面返回到上一级(二级或三级上层)。
|
||||||
|
|
||||||
### 4.2 状态与数据边界
|
### 4.2 状态与数据边界
|
||||||
|
|
||||||
@@ -151,6 +159,13 @@
|
|||||||
2. 硬过期数据需可见提醒(弱提示,不阻断基础浏览)。
|
2. 硬过期数据需可见提醒(弱提示,不阻断基础浏览)。
|
||||||
3. 提供稳定手动刷新入口。
|
3. 提供稳定手动刷新入口。
|
||||||
|
|
||||||
|
### 6.5 日历提醒取消动作的一致性兜底
|
||||||
|
|
||||||
|
1. 用户在提醒弹层点击“取消/归档”时,前端必须立即发送归档请求,要求后端立刻将事件归档/过期。
|
||||||
|
2. “延迟归档(outbox/pending)”仅在 App 进程不可用(被杀/未启动)时生效,作为离线或冷启动兜底。
|
||||||
|
3. App 冷启动或恢复前台后,必须优先冲刷 pending 归档请求,确保最终一致性。
|
||||||
|
4. 对用户可见行为要求:点击取消后 UI 立即反映归档状态,网络失败时展示重试提示,并保留 pending 记录。
|
||||||
|
|
||||||
## 7. 导航与页面职责重构
|
## 7. 导航与页面职责重构
|
||||||
|
|
||||||
### 7.1 路由重构
|
### 7.1 路由重构
|
||||||
@@ -165,6 +180,8 @@
|
|||||||
1. Dock Home 统一执行“切到 Home 分支/`go('/home')`”。
|
1. Dock Home 统一执行“切到 Home 分支/`go('/home')`”。
|
||||||
2. `returnToHomePreserveState` 仅用于非 Dock 的返回策略场景。
|
2. `returnToHomePreserveState` 仅用于非 Dock 的返回策略场景。
|
||||||
3. 消除 `canPop -> pop` 对主页按钮语义的影响。
|
3. 消除 `canPop -> pop` 对主页按钮语义的影响。
|
||||||
|
4. 二级页面(Calendar Day/Month、Todo、Settings)统一拦截系统返回和侧滑返回,目标固定为 Home。
|
||||||
|
5. App 退出只允许在 Home 页面生效(可采用双击退出或系统默认行为)。
|
||||||
|
|
||||||
### 7.3 页面职责收敛
|
### 7.3 页面职责收敛
|
||||||
|
|
||||||
@@ -182,6 +199,7 @@
|
|||||||
1. 引入 shell + 分支保活。
|
1. 引入 shell + 分支保活。
|
||||||
2. Dock 接口改造与主 tab 切换实现。
|
2. Dock 接口改造与主 tab 切换实现。
|
||||||
3. Home 按钮语义修正。
|
3. Home 按钮语义修正。
|
||||||
|
4. 建立分级返回约束:二级 -> Home,三级 -> 上一级,退出仅 Home。
|
||||||
|
|
||||||
### M2 统一缓存骨架
|
### M2 统一缓存骨架
|
||||||
|
|
||||||
@@ -206,6 +224,7 @@
|
|||||||
1. 清理旧缓存与重复加载逻辑。
|
1. 清理旧缓存与重复加载逻辑。
|
||||||
2. 补齐测试与性能观测。
|
2. 补齐测试与性能观测。
|
||||||
3. 评估参数并收敛默认策略。
|
3. 评估参数并收敛默认策略。
|
||||||
|
4. 验证提醒“点击取消即实时归档”与“App 关闭时延迟归档兜底”双路径。
|
||||||
|
|
||||||
## 9. 验收标准
|
## 9. 验收标准
|
||||||
|
|
||||||
@@ -215,18 +234,21 @@
|
|||||||
2. 日/月切换响应明显变快。
|
2. 日/月切换响应明显变快。
|
||||||
3. 首次冷启动可先看到本地缓存内容。
|
3. 首次冷启动可先看到本地缓存内容。
|
||||||
4. Dock Home 始终回主页。
|
4. Dock Home 始终回主页。
|
||||||
|
5. 二级页面侧滑返回永远回 Home,不直接退出 App。
|
||||||
|
|
||||||
### 9.2 网络验收
|
### 9.2 网络验收
|
||||||
|
|
||||||
1. 切换页面时网络请求显著减少。
|
1. 切换页面时网络请求显著减少。
|
||||||
2. 写操作后关联数据可及时更新。
|
2. 写操作后关联数据可及时更新。
|
||||||
3. 手动刷新可强制拉取并回写缓存。
|
3. 手动刷新可强制拉取并回写缓存。
|
||||||
|
4. 提醒取消动作触发实时归档请求,成功率可观测。
|
||||||
|
|
||||||
### 9.3 一致性验收
|
### 9.3 一致性验收
|
||||||
|
|
||||||
1. 不出现旧响应覆盖新数据。
|
1. 不出现旧响应覆盖新数据。
|
||||||
2. 离线后恢复在线可自动静默同步。
|
2. 离线后恢复在线可自动静默同步。
|
||||||
3. 软过期/硬过期行为符合策略定义。
|
3. 软过期/硬过期行为符合策略定义。
|
||||||
|
4. 提醒归档在在线/离线/冷启动场景下保持最终一致。
|
||||||
|
|
||||||
## 10. 测试与验证计划
|
## 10. 测试与验证计划
|
||||||
|
|
||||||
@@ -241,12 +263,15 @@
|
|||||||
1. Dock 切换不重建分支主页面。
|
1. Dock 切换不重建分支主页面。
|
||||||
2. 日/月切换不重复触发全量加载。
|
2. 日/月切换不重复触发全量加载。
|
||||||
3. Home 按钮行为稳定。
|
3. Home 按钮行为稳定。
|
||||||
|
4. 二级页面系统返回不会触发 App 退出。
|
||||||
|
|
||||||
### 10.3 集成回归
|
### 10.3 集成回归
|
||||||
|
|
||||||
1. Calendar -> Todo -> Calendar 多轮切换请求计数。
|
1. Calendar -> Todo -> Calendar 多轮切换请求计数。
|
||||||
2. Todo 完成后列表更新与缓存一致性。
|
2. Todo 完成后列表更新与缓存一致性。
|
||||||
3. profile 更新后设置页/其他依赖页可见一致。
|
3. profile 更新后设置页/其他依赖页可见一致。
|
||||||
|
4. 提醒取消 -> 立即归档 -> 日历列表刷新链路。
|
||||||
|
5. App 杀进程后触发提醒,重启后 pending 归档自动冲刷。
|
||||||
|
|
||||||
## 11. 风险与回滚
|
## 11. 风险与回滚
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,61 @@
|
|||||||
|
|
||||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
**Goal:** 完成 Home/Calendar/Todo 的解耦切换与统一缓存改造,实现本地优先显示、后台静默刷新、写后精准失效,并修复 Dock 回主页语义。
|
**Goal:** 完成导航分级回退(一级唯一 Home)与统一缓存改造,实现本地优先显示、后台静默刷新、写后精准失效,并落地“提醒取消即实时归档 + App 关闭时延迟归档兜底”。
|
||||||
|
|
||||||
**Architecture:** 路由层采用 `StatefulShellRoute.indexedStack` 维持主分支保活;数据层新增 `core/cache` 统一缓存模块(memory + persistent + hybrid);业务层通过 repository 接入缓存策略,页面仅负责发意图和渲染状态。写操作触发精准失效,读取遵循 soft/hard TTL + minimum refresh interval。
|
**Architecture:** 路由层采用“一级唯一 Home + 二级业务页 + 三级细节页”的分级返回模型,二级页面返回统一回 Home,退出入口仅 Home;数据层新增 `core/cache` 统一缓存模块(memory + persistent + hybrid);业务层通过 repository 接入缓存策略与失效器。提醒动作采用实时归档优先,pending outbox 仅用于 App 不可用场景兜底。
|
||||||
|
|
||||||
**Tech Stack:** Flutter, go_router, get_it, shared_preferences, flutter_test, mocktail
|
**Tech Stack:** Flutter, go_router, get_it, shared_preferences, flutter_test, mocktail
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Task 0: 锁定导航分级与退出语义(一级/二级/三级)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/lib/core/router/app_router.dart`
|
||||||
|
- Modify: `apps/lib/features/home/ui/navigation/home_return_policy.dart`
|
||||||
|
- Modify: `apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart`
|
||||||
|
- Modify: `apps/lib/features/calendar/ui/screens/calendar_month_screen.dart`
|
||||||
|
- Modify: `apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart`
|
||||||
|
- Modify: `apps/lib/features/settings/ui/screens/settings_screen.dart`
|
||||||
|
- Test: `apps/test/features/home/ui/navigation/home_return_policy_test.dart`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
test('second-level pages should return to home instead of exiting app', () {
|
||||||
|
final action = resolveHomeReturnAction(
|
||||||
|
canPop: false,
|
||||||
|
isAuthEntry: false,
|
||||||
|
forceGoHome: true,
|
||||||
|
);
|
||||||
|
expect(action, HomeReturnAction.goHome);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd apps && flutter test test/features/home/ui/navigation/home_return_policy_test.dart`
|
||||||
|
Expected: FAIL with old return behavior.
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
if (forceGoHome) return HomeReturnAction.goHome;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd apps && flutter test test/features/home/ui/navigation/home_return_policy_test.dart`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/lib/core/router/app_router.dart apps/lib/features/home/ui/navigation/home_return_policy.dart apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart apps/lib/features/calendar/ui/screens/calendar_month_screen.dart apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart apps/lib/features/settings/ui/screens/settings_screen.dart apps/test/features/home/ui/navigation/home_return_policy_test.dart
|
||||||
|
git commit -m "feat: enforce hierarchical back navigation and home-only exit"
|
||||||
|
```
|
||||||
|
|
||||||
### Task 1: 建立统一缓存核心模型与策略
|
### Task 1: 建立统一缓存核心模型与策略
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
@@ -376,7 +423,54 @@ git add apps/lib/core/cache/cache_refresh_coordinator.dart apps/lib/main.dart ap
|
|||||||
git commit -m "feat: add app lifecycle refresh coordinator"
|
git commit -m "feat: add app lifecycle refresh coordinator"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Task 9: 全量验证与文档同步
|
### Task 9: 提醒取消实时归档与延迟归档兜底收敛
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/lib/features/calendar/reminders/reminder_action_executor.dart`
|
||||||
|
- Modify: `apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart`
|
||||||
|
- Modify: `apps/lib/core/notifications/local_notification_service.dart`
|
||||||
|
- Modify: `apps/lib/main.dart`
|
||||||
|
- Modify: `apps/test/features/calendar/reminders/reminder_action_executor_test.dart`
|
||||||
|
- Modify: `apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
test('archive action should send remote archive immediately when app active', () async {
|
||||||
|
// Arrange active app + online gateway
|
||||||
|
// Act archive action
|
||||||
|
// Assert remote archive called once and local pending outbox not created
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd apps && flutter test test/features/calendar/reminders/reminder_action_executor_test.dart`
|
||||||
|
Expected: FAIL with delayed-only behavior.
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
if (isAppActive) {
|
||||||
|
await repository.archiveNow(eventId);
|
||||||
|
} else {
|
||||||
|
await outbox.enqueueArchive(eventId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd apps && flutter test test/features/calendar/reminders/reminder_action_executor_test.dart test/features/calendar/reminders/reminder_notification_bridge_test.dart`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/lib/features/calendar/reminders/reminder_action_executor.dart apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart apps/lib/core/notifications/local_notification_service.dart apps/lib/main.dart apps/test/features/calendar/reminders/reminder_action_executor_test.dart apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart
|
||||||
|
git commit -m "fix: prioritize realtime reminder archive with cold-start fallback"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 10: 全量验证与文档同步
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `docs/protocols/*`(仅当路由/数据契约文档需更新时)
|
- Modify: `docs/protocols/*`(仅当路由/数据契约文档需更新时)
|
||||||
@@ -409,6 +503,8 @@ Expected: No errors.
|
|||||||
3. 日/月切换不触发无必要请求。
|
3. 日/月切换不触发无必要请求。
|
||||||
4. Dock Home 始终回主页。
|
4. Dock Home 始终回主页。
|
||||||
5. 写后数据可见一致,失败可回滚提示。
|
5. 写后数据可见一致,失败可回滚提示。
|
||||||
|
6. 二级页面侧滑返回只回 Home,不直接退出。
|
||||||
|
7. 提醒点击取消时立刻归档;仅在 App 不可用时走 pending 兜底。
|
||||||
|
|
||||||
**Step 5: Commit**
|
**Step 5: Commit**
|
||||||
|
|
||||||
@@ -419,16 +515,17 @@ git commit -m "docs: finalize navigation decoupling and unified cache rollout"
|
|||||||
|
|
||||||
## 实施顺序约束
|
## 实施顺序约束
|
||||||
|
|
||||||
1. 必须先完成 Task 1-3 再改业务页面(否则会出现重复实现)。
|
1. 必须先完成 Task 0-3 再改业务页面(否则会出现重复实现)。
|
||||||
2. Task 5(路由壳层)与 Task 6/7(业务接入)要分开提交,便于回滚。
|
2. Task 0(分级返回)与 Task 5(路由壳层)要分开提交,便于单独回滚。
|
||||||
3. 每个 Task 的测试必须在本 Task 完成后立即执行,避免堆积回归。
|
3. 每个 Task 的测试必须在本 Task 完成后立即执行,避免堆积回归。
|
||||||
4. 不允许在未通过 focused tests 的情况下进入全量验证。
|
4. 不允许在未通过 focused tests 的情况下进入全量验证。
|
||||||
|
|
||||||
## 回滚策略
|
## 回滚策略
|
||||||
|
|
||||||
1. 若导航回归:回滚 Task 5 提交,保留缓存模块提交。
|
1. 若返回语义回归:先回滚 Task 0 提交,再评估 Task 5。
|
||||||
2. 若缓存一致性异常:优先回滚 Task 6/7 的 repository 接入提交。
|
2. 若缓存一致性异常:优先回滚 Task 6/7 的 repository 接入提交。
|
||||||
3. 若生命周期刷新过于频繁:关闭 Task 8 coordinator 挂载,保留手动刷新兜底。
|
3. 若生命周期刷新过于频繁:关闭 Task 8 coordinator 挂载,保留手动刷新兜底。
|
||||||
|
4. 若提醒实时归档异常:回滚 Task 9,仅保留 outbox 兜底路径。
|
||||||
|
|
||||||
## Done 定义
|
## Done 定义
|
||||||
|
|
||||||
@@ -436,3 +533,5 @@ git commit -m "docs: finalize navigation decoupling and unified cache rollout"
|
|||||||
2. 主页按钮行为稳定,无“返回上一页”误行为。
|
2. 主页按钮行为稳定,无“返回上一页”误行为。
|
||||||
3. 切换页面请求数明显下降,写后一致性符合设计预期。
|
3. 切换页面请求数明显下降,写后一致性符合设计预期。
|
||||||
4. 统一缓存已接管用户信息、日历、待办三域。
|
4. 统一缓存已接管用户信息、日历、待办三域。
|
||||||
|
5. 二级页面不再可直接侧滑退出 App。
|
||||||
|
6. 提醒归档满足“实时优先、关闭兜底”策略。
|
||||||
|
|||||||
Reference in New Issue
Block a user