fix: jump required banner to linked task
This commit is contained in:
parent
adf5422917
commit
1e0e76d72f
5 changed files with 106 additions and 16 deletions
|
|
@ -113,7 +113,7 @@ class _PlanIssue extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TodayScreenScaffold extends StatelessWidget {
|
class _TodayScreenScaffold extends StatefulWidget {
|
||||||
const _TodayScreenScaffold({
|
const _TodayScreenScaffold({
|
||||||
required this.data,
|
required this.data,
|
||||||
required this.selectedCard,
|
required this.selectedCard,
|
||||||
|
|
@ -126,6 +126,25 @@ class _TodayScreenScaffold extends StatelessWidget {
|
||||||
final ValueChanged<TimelineCardModel> onSelectCard;
|
final ValueChanged<TimelineCardModel> onSelectCard;
|
||||||
final VoidCallback onClearSelection;
|
final VoidCallback onClearSelection;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_TodayScreenScaffold> createState() => _TodayScreenScaffoldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
||||||
|
String? _timelineScrollTargetCardId;
|
||||||
|
var _timelineScrollRequest = 0;
|
||||||
|
|
||||||
|
void _showUpcomingRequiredTask() {
|
||||||
|
final targetCardId = widget.data.requiredBanner?.timelineCardId;
|
||||||
|
if (targetCardId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_timelineScrollTargetCardId = targetCardId;
|
||||||
|
_timelineScrollRequest += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Stack(
|
return Stack(
|
||||||
|
|
@ -140,33 +159,38 @@ class _TodayScreenScaffold extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
TopBar(dateLabel: data.dateLabel),
|
TopBar(dateLabel: widget.data.dateLabel),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
RequiredBanner(model: data.requiredBanner),
|
RequiredBanner(
|
||||||
|
model: widget.data.requiredBanner,
|
||||||
|
onShowUpcoming: _showUpcomingRequiredTask,
|
||||||
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TimelineView(
|
child: TimelineView(
|
||||||
cards: data.cards,
|
cards: widget.data.cards,
|
||||||
range: data.timelineRange,
|
range: widget.data.timelineRange,
|
||||||
onCardSelected: onSelectCard,
|
scrollTargetCardId: _timelineScrollTargetCardId,
|
||||||
|
scrollRequest: _timelineScrollRequest,
|
||||||
|
onCardSelected: widget.onSelectCard,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (selectedCard != null) ...[
|
if (widget.selectedCard != null) ...[
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
key: const ValueKey('modal-click-away'),
|
key: const ValueKey('modal-click-away'),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: onClearSelection,
|
onTap: widget.onClearSelection,
|
||||||
child: const ColoredBox(color: Colors.transparent),
|
child: const ColoredBox(color: Colors.transparent),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Center(
|
Center(
|
||||||
child: TaskSelectionModal(
|
child: TaskSelectionModal(
|
||||||
card: selectedCard!,
|
card: widget.selectedCard!,
|
||||||
onClose: onClearSelection,
|
onClose: widget.onClearSelection,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,14 @@ import 'package:scheduler_core/scheduler_core.dart';
|
||||||
/// Presentation model for the next-required-task banner.
|
/// Presentation model for the next-required-task banner.
|
||||||
class RequiredBannerModel {
|
class RequiredBannerModel {
|
||||||
/// Creates a required banner model.
|
/// Creates a required banner model.
|
||||||
const RequiredBannerModel({required this.title, required this.timeText});
|
const RequiredBannerModel({
|
||||||
|
required this.timelineCardId,
|
||||||
|
required this.title,
|
||||||
|
required this.timeText,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Timeline card id linked to the highlighted required task.
|
||||||
|
final String timelineCardId;
|
||||||
|
|
||||||
/// Title of the next required task.
|
/// Title of the next required task.
|
||||||
final String title;
|
final String title;
|
||||||
|
|
@ -79,6 +86,7 @@ class TodayScreenData {
|
||||||
requiredBanner: nextRequired == null
|
requiredBanner: nextRequired == null
|
||||||
? null
|
? null
|
||||||
: RequiredBannerModel(
|
: RequiredBannerModel(
|
||||||
|
timelineCardId: nextRequired.id,
|
||||||
title: nextRequired.item.displayTitle,
|
title: nextRequired.item.displayTitle,
|
||||||
timeText: _formatTime(nextRequired.start),
|
timeText: _formatTime(nextRequired.start),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,14 @@ import '../theme/focus_flow_tokens.dart';
|
||||||
/// Banner that highlights the next required task, if one exists.
|
/// Banner that highlights the next required task, if one exists.
|
||||||
class RequiredBanner extends StatelessWidget {
|
class RequiredBanner extends StatelessWidget {
|
||||||
/// Creates a required-task banner.
|
/// Creates a required-task banner.
|
||||||
const RequiredBanner({this.model, super.key});
|
const RequiredBanner({this.model, this.onShowUpcoming, super.key});
|
||||||
|
|
||||||
/// Optional required task model to render.
|
/// Optional required task model to render.
|
||||||
final RequiredBannerModel? model;
|
final RequiredBannerModel? model;
|
||||||
|
|
||||||
|
/// Callback invoked when the highlighted required task should be shown.
|
||||||
|
final VoidCallback? onShowUpcoming;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final banner = model;
|
final banner = model;
|
||||||
|
|
@ -85,7 +88,7 @@ class RequiredBanner extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: metrics.buttonGap),
|
SizedBox(width: metrics.buttonGap),
|
||||||
_UpcomingButton(metrics: metrics),
|
_UpcomingButton(metrics: metrics, onPressed: onShowUpcoming),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -127,14 +130,15 @@ class _RequiredBannerMetrics {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UpcomingButton extends StatelessWidget {
|
class _UpcomingButton extends StatelessWidget {
|
||||||
const _UpcomingButton({required this.metrics});
|
const _UpcomingButton({required this.metrics, required this.onPressed});
|
||||||
|
|
||||||
final _RequiredBannerMetrics metrics;
|
final _RequiredBannerMetrics metrics;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return OutlinedButton(
|
return OutlinedButton(
|
||||||
onPressed: () {},
|
onPressed: onPressed,
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: FocusFlowTokens.textPrimary,
|
foregroundColor: FocusFlowTokens.textPrimary,
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ class TimelineView extends StatefulWidget {
|
||||||
required this.range,
|
required this.range,
|
||||||
required this.onCardSelected,
|
required this.onCardSelected,
|
||||||
this.now,
|
this.now,
|
||||||
|
this.scrollTargetCardId,
|
||||||
|
this.scrollRequest = 0,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -32,6 +34,12 @@ class TimelineView extends StatefulWidget {
|
||||||
/// Clock used to center the initial scroll position.
|
/// Clock used to center the initial scroll position.
|
||||||
final DateTime Function()? now;
|
final DateTime Function()? now;
|
||||||
|
|
||||||
|
/// Card id requested by another surface, such as the required banner.
|
||||||
|
final String? scrollTargetCardId;
|
||||||
|
|
||||||
|
/// Monotonic request number that allows repeated jumps to the same card.
|
||||||
|
final int scrollRequest;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<TimelineView> createState() => _TimelineViewState();
|
State<TimelineView> createState() => _TimelineViewState();
|
||||||
}
|
}
|
||||||
|
|
@ -39,6 +47,7 @@ class TimelineView extends StatefulWidget {
|
||||||
class _TimelineViewState extends State<TimelineView> {
|
class _TimelineViewState extends State<TimelineView> {
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
bool _didSetInitialScroll = false;
|
bool _didSetInitialScroll = false;
|
||||||
|
var _handledScrollRequest = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant TimelineView oldWidget) {
|
void didUpdateWidget(covariant TimelineView oldWidget) {
|
||||||
|
|
@ -67,6 +76,7 @@ class _TimelineViewState extends State<TimelineView> {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
_scheduleInitialScroll(geometry, constraints.maxHeight);
|
_scheduleInitialScroll(geometry, constraints.maxHeight);
|
||||||
|
_scheduleTargetScroll(geometry);
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
|
|
@ -131,4 +141,33 @@ class _TimelineViewState extends State<TimelineView> {
|
||||||
_didSetInitialScroll = true;
|
_didSetInitialScroll = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _scheduleTargetScroll(TimelineGeometry geometry) {
|
||||||
|
final targetCardId = widget.scrollTargetCardId;
|
||||||
|
if (targetCardId == null || widget.scrollRequest == _handledScrollRequest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_scrollController.hasClients) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TimelineCardModel? targetCard;
|
||||||
|
for (final card in widget.cards) {
|
||||||
|
if (card.id == targetCardId) {
|
||||||
|
targetCard = card;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetCard == null) {
|
||||||
|
_handledScrollRequest = widget.scrollRequest;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final targetOffset =
|
||||||
|
geometry.yForMinutesSinceMidnight(targetCard.startMinutes) - 24;
|
||||||
|
final maxOffset = _scrollController.position.maxScrollExtent;
|
||||||
|
final offset = targetOffset.clamp(0, maxOffset).toDouble();
|
||||||
|
_scrollController.jumpTo(offset);
|
||||||
|
_handledScrollRequest = widget.scrollRequest;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,17 @@ void main() {
|
||||||
expect(TodayScreenData.defaultRange.endMinutes, 24 * 60);
|
expect(TodayScreenData.defaultRange.endMinutes, 24 * 60);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('required banner jumps timeline to linked task', (tester) async {
|
||||||
|
await _pumpPlanApp(tester);
|
||||||
|
|
||||||
|
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
|
||||||
|
await tester.tap(find.text('Show upcoming'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(scrollable.position.pixels, closeTo(2622, 1));
|
||||||
|
expect(find.byKey(const ValueKey('pay-bill')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'timeline defaults near the current time while keeping full day',
|
'timeline defaults near the current time while keeping full day',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|
@ -171,7 +182,11 @@ void main() {
|
||||||
tester,
|
tester,
|
||||||
size: const Size(440, 72),
|
size: const Size(440, 72),
|
||||||
child: const RequiredBanner(
|
child: const RequiredBanner(
|
||||||
model: RequiredBannerModel(title: 'Pay bill', timeText: '6:00 PM'),
|
model: RequiredBannerModel(
|
||||||
|
timelineCardId: 'pay-bill',
|
||||||
|
title: 'Pay bill',
|
||||||
|
timeText: '6:00 PM',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue