From 8d6741ecc114cbbc6621aa6fc557086196e0ae50 Mon Sep 17 00:00:00 2001 From: miakh <2659269+miakh@users.noreply.github.com> Date: Wed, 24 Jan 2024 00:37:40 +0100 Subject: [PATCH] added timtable animations --- lib/data/DataService.dart | 2 +- lib/pages/ProgramViewPage.dart | 10 +- lib/widgets/Timetable.dart | 488 ++++++++++++++++++++------------- 3 files changed, 299 insertions(+), 201 deletions(-) diff --git a/lib/data/DataService.dart b/lib/data/DataService.dart index fbfdb148..7d2bcde7 100644 --- a/lib/data/DataService.dart +++ b/lib/data/DataService.dart @@ -11,7 +11,7 @@ import 'package:avapp/services/DialogHelper.dart'; import 'package:avapp/services/NotificationHelper.dart'; import 'package:avapp/services/ToastHelper.dart'; import 'package:avapp/services/UserManagementHelper.dart'; -import 'package:avapp/widgets/TimeTable.dart'; +import 'package:avapp/widgets/Timetable.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; diff --git a/lib/pages/ProgramViewPage.dart b/lib/pages/ProgramViewPage.dart index 7a722c94..d552b842 100644 --- a/lib/pages/ProgramViewPage.dart +++ b/lib/pages/ProgramViewPage.dart @@ -1,7 +1,7 @@ import 'package:avapp/data/DataService.dart'; import 'package:avapp/pages/ProgramPage.dart'; import 'package:avapp/services/NavigationHelper.dart'; -import 'package:avapp/widgets/TimeTable.dart'; +import 'package:avapp/widgets/Timetable.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -22,6 +22,8 @@ class _ProgramViewPageState extends State with TickerProviderStateMixin { late TabController _tabController; + var timetableController = TimetableController(); + @override void initState() { super.initState(); @@ -52,6 +54,7 @@ class _ProgramViewPageState extends State _tabController.addListener(() { setState(() { _currentIndex = _tabController.index; + timetableController.reset?.call(); }); }); await loadEventParticipants(); @@ -120,13 +123,14 @@ class _ProgramViewPageState extends State ), ], ), - body: TimeTable( + body: Timetable( + controller: timetableController, items: _items .where((element) => element.startTime.weekday == _days.keys.toList()[_currentIndex]) .toList(), - timetablePlaces: _timetablePlaces), + timetablePlaces: _timetablePlaces) ); } diff --git a/lib/widgets/Timetable.dart b/lib/widgets/Timetable.dart index bae1b219..c597f1eb 100644 --- a/lib/widgets/Timetable.dart +++ b/lib/widgets/Timetable.dart @@ -7,25 +7,34 @@ import 'package:avapp/widgets/ButtonsHelper.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -class TimeTable extends StatefulWidget { - const TimeTable({ +class TimetableController { + void Function()? reset; +} + +class Timetable extends StatefulWidget { + final TimetableController? controller; + + const Timetable({ super.key, required this.items, required this.timetablePlaces, + this.controller }); final List items; final List timetablePlaces; @override - State createState() => _TimeTableState(); + State createState() => _TimetableState(controller); } -class _TimeTableState extends State { +class _TimetableState extends State with TickerProviderStateMixin { final double pixelsInHour = 200; final double placeTitleHeight = 40; final double timelineHeight = 30; final double itemHeight = 56; + final int animationDuration = 1000; + final double velocityAnimationSpeed = 0.5; Offset offset = const Offset(0, 0); @@ -40,153 +49,225 @@ class _TimeTableState extends State { int? firstHour; int? lastHour; - double getTimeTableHeight() => widget.timetablePlaces.length*(placeTitleHeight+itemHeight)+timelineHeight; - double getTimeTableWidth() => (hourCount??24)*pixelsInHour; - double getWidgetHeight() => getTimeTableHeight()>constraints.maxHeight?getTimeTableHeight():constraints.maxHeight; + _TimetableState(TimetableController? timetableController) { + if(timetableController!=null){ + timetableController.reset = () { + _animationController.stop(); + setOffset(const Offset(0,0)); + }; + } + } + + double getTimetableHeight() => + widget.timetablePlaces.length * (placeTitleHeight + itemHeight) + + timelineHeight; + + double getTimetableWidth() => (hourCount ?? 24) * pixelsInHour; + + double getWidgetHeight() => getTimetableHeight() > constraints.maxHeight + ? getTimetableHeight() + : constraints.maxHeight; + + late AnimationController _animationController; + late Animation animationX = + CurvedAnimation(parent: _animationController, curve: Curves.easeIn); + late Animation animationY = + CurvedAnimation(parent: _animationController, curve: Curves.easeIn); @override - Widget build(BuildContext context) { + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, duration: Duration(milliseconds: animationDuration)); + _animationController.addListener(() { + setState(() { + matrixPlaceTitles.setTranslationRaw(0, animationY.value, 0); + matrixTimeline.setTranslationRaw(animationX.value, 0, 0); + matrixTimetable.setTranslationRaw( + animationX.value, animationY.value, 0); + }); + }); + } - if(widget.timetablePlaces.isEmpty||widget.timetablePlaces.isEmpty) { - return SizedBox.shrink(); + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.timetablePlaces.isEmpty || widget.timetablePlaces.isEmpty) { + return const SizedBox.shrink(); } return LayoutBuilder( builder: (BuildContext context, BoxConstraints cConstraints) { - constraints = cConstraints; + constraints = cConstraints; - List allItems = buildTimeline(); + List allItems = buildTimeline(); - var timetableItems = Transform( - transformHitTests: true, - transform: matrixTimetable, - child: Stack( - children: allItems, - ), - ); - List stackChildren = [timetableItems]; - - var placeTitles = Transform( - transform: matrixPlaceTitles, - child: Stack( - children: List.generate(widget.timetablePlaces.length, (i) => Padding( - padding: EdgeInsets.fromLTRB(0, i*(itemHeight+placeTitleHeight)+timelineHeight, 0, 0), + var timetableItems = Transform( + transformHitTests: true, + transform: matrixTimetable, + child: Stack( + children: allItems, + ), + ); + List stackChildren = [timetableItems]; + + var placeTitles = Transform( + transform: matrixPlaceTitles, + child: Stack( + children: List.generate( + widget.timetablePlaces.length, + (i) => Padding( + padding: EdgeInsets.fromLTRB( + 0, + i * (itemHeight + placeTitleHeight) + timelineHeight, + 0, + 0), child: Container( height: placeTitleHeight, alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.all(8.0), - child: Text(widget.timetablePlaces[i].title, style: const TextStyle(fontWeight: FontWeight.bold),), + child: Text( + widget.timetablePlaces[i].title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), ), ), )), - ), - ); - stackChildren.add(placeTitles); - - var timeline = Transform( - transform: matrixTimeline, - child: Stack( - children: List.generate(hourCount!+1, (i) { - var hour = firstHour!+i; - if(hour>23) - { - hour-=24; - } - return Padding( - padding: EdgeInsets.fromLTRB(i==0?0:i*pixelsInHour-pixelsInHour/2, 0, 0, 0), - child: Container( - color: AppConfig.color1, - height: timelineHeight, - width: (i==hourCount!||i==0)?pixelsInHour/2:pixelsInHour, - alignment: i==0?Alignment.centerLeft:i==hourCount!?Alignment.centerRight:Alignment.center, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text("$hour:00", style: const TextStyle(color: Colors.white),), - ), + ), + ); + stackChildren.add(placeTitles); + + var timeline = Transform( + transform: matrixTimeline, + child: Stack( + children: List.generate(hourCount! + 1, (i) { + var hour = firstHour! + i; + if (hour > 23) { + hour -= 24; + } + return Padding( + padding: EdgeInsets.fromLTRB( + i == 0 ? 0 : i * pixelsInHour - pixelsInHour / 2, 0, 0, 0), + child: Container( + color: AppConfig.color1, + height: timelineHeight, + width: (i == hourCount! || i == 0) + ? pixelsInHour / 2 + : pixelsInHour, + alignment: i == 0 + ? Alignment.centerLeft + : i == hourCount! + ? Alignment.centerRight + : Alignment.center, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + "$hour:00", + style: const TextStyle(color: Colors.white), ), - ); - }), - ), - ); - stackChildren.add(timeline); + ), + ), + ); + }), + ), + ); + stackChildren.add(timeline); - return Stack( - children: [ - GestureDetector( + return Stack( + children: [ + GestureDetector( behavior: HitTestBehavior.translucent, - onPanUpdate: constrainBoundaries, - child: SingleChildScrollView( - physics: const NeverScrollableScrollPhysics(), + onPanStart: panStarted, + onPanUpdate: enforceConstraints, + onPanEnd: panEnded, + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), scrollDirection: Axis.horizontal, - child: SingleChildScrollView( - physics: const NeverScrollableScrollPhysics(), - scrollDirection: Axis.vertical, child: Stack(children: stackChildren,))))], - ); - } - ); + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + scrollDirection: Axis.vertical, + child: Stack( + children: stackChildren, + )))) + ], + ); + }); } //support max 1 day skipping - double timeRangeLength(double pixelsPerHour, DateTime startTime, DateTime endTime) - { + double timeRangeLength( + double pixelsPerHour, DateTime startTime, DateTime endTime) { var range = DateTimeRange(start: startTime, end: endTime); - return range.duration.inMinutes/60.0*pixelsPerHour; + return range.duration.inMinutes / 60.0 * pixelsPerHour; } List buildTimeline() { List allItems = []; - var firstEvent = widget.items.reduce( - (current, next) => current.startTime.compareTo(next.startTime) < 0 ? current : next); + var firstEvent = widget.items.reduce((current, next) => + current.startTime.compareTo(next.startTime) < 0 ? current : next); - var lastEvent = widget.items.reduce( - (current, next) => current.endTime.compareTo(next.endTime) > 0 ? current : next); + var lastEvent = widget.items.reduce((current, next) => + current.endTime.compareTo(next.endTime) > 0 ? current : next); - var range = DateTimeRange(start: firstEvent.startTime, end: lastEvent.endTime); - if(range.duration.inHours>48) - { + var range = + DateTimeRange(start: firstEvent.startTime, end: lastEvent.endTime); + if (range.duration.inHours > 48) { throw Exception("Events range cannot exceed 48 hours."); } firstHour = firstEvent.startTime.hour; - lastHour = lastEvent.endTime.minute > 0 ? lastEvent.endTime.hour + 1 : lastEvent.endTime.hour; + lastHour = lastEvent.endTime.minute > 0 + ? lastEvent.endTime.hour + 1 + : lastEvent.endTime.hour; bool isSkipping = firstEvent.startTime.day != lastEvent.endTime.day; - hourCount = isSkipping ? 24 - firstHour! + 24 - lastHour! : lastHour! - firstHour!; - - allItems.add( - Row( - children: - List.generate( - hourCount!, (i) => Container( - width: pixelsInHour, - height: getWidgetHeight(), - decoration: BoxDecoration( - border: Border( - left: BorderSide(width: 0.25, color: Colors.grey), - right: BorderSide(width: 0.25, color: Colors.grey), - ), - color: i%2==0 ? Colors.white70:Colors.white70, + hourCount = + isSkipping ? 24 - firstHour! + 24 - lastHour! : lastHour! - firstHour!; + + allItems.add(Row( + children: List.generate( + hourCount!, + (i) => Container( + width: pixelsInHour, + height: getWidgetHeight(), + decoration: BoxDecoration( + border: const Border( + left: BorderSide(width: 0.25, color: Colors.grey), + right: BorderSide(width: 0.25, color: Colors.grey), ), + color: i % 2 == 0 ? Colors.white70 : Colors.white70, ), ), + ), )); - for(var p = 0; p < widget.timetablePlaces.length; p++) - { - var pItems = widget.items.where((element) => element.placeId==widget.timetablePlaces[p].id).toList(); - for(var i = 0; i < pItems.length; i++) - { + for (var p = 0; p < widget.timetablePlaces.length; p++) { + var pItems = widget.items + .where((element) => element.placeId == widget.timetablePlaces[p].id) + .toList(); + for (var i = 0; i < pItems.length; i++) { var item = pItems[i]; var timeBlock = Positioned( - left: timeRangeLength(pixelsInHour, firstEvent.startTime, item.startTime), - top: (placeTitleHeight+itemHeight)*p+placeTitleHeight+timelineHeight, + left: timeRangeLength( + pixelsInHour, firstEvent.startTime, item.startTime), + top: (placeTitleHeight + itemHeight) * p + + placeTitleHeight + + timelineHeight, child: GestureDetector( - onTap: ()=>context.push("${EventPage.ROUTE}/${item.id}"), + onTap: () => context.push("${EventPage.ROUTE}/${item.id}"), child: Container( - width: timeRangeLength(pixelsInHour, item.startTime, item.endTime), + width: + timeRangeLength(pixelsInHour, item.startTime, item.endTime), height: itemHeight, decoration: BoxDecoration( - color: item.itemType==TimeTableItemType.signed?AppConfig.color2:Colors.black26, + color: item.itemType == TimetableItemType.signed + ? AppConfig.color2 + : Colors.black26, borderRadius: BorderRadius.circular(6), ), child: Stack( @@ -194,14 +275,21 @@ class _TimeTableState extends State { Row( mainAxisAlignment: MainAxisAlignment.end, children: ButtonsHelper.getAddToMyProgramButton( - TimetableItem.getTimeTableItemTypeAsCanSignIn(item.itemType), - () - async {await addToMyProgram(item);}, () async {await removeFromMyProgram(item);}, - Colors.white), + TimetableItem.getTimetableItemTypeAsCanSignIn( + item.itemType), () async { + await addToMyProgram(item); + }, () async { + await removeFromMyProgram(item); + }, Colors.white), ), Padding( padding: const EdgeInsets.fromLTRB(8, 8, 40, 8), - child: Text(item.text, style: TextStyle(color: item.itemType==TimeTableItemType.signed?Colors.white:Colors.black), overflow: TextOverflow.fade), + child: Text(item.text, + style: TextStyle( + color: item.itemType == TimetableItemType.signed + ? Colors.white + : Colors.black), + overflow: TextOverflow.fade), ), ], ), @@ -218,76 +306,92 @@ class _TimeTableState extends State { Future addToMyProgram(TimetableItem item) async { await DataService.addToMyProgram(item.id); setState(() { - item.itemType = TimeTableItemType.signed; + item.itemType = TimetableItemType.signed; }); } Future removeFromMyProgram(TimetableItem item) async { await DataService.removeFromMyProgram(item.id); setState(() { - item.itemType = TimeTableItemType.notSigned; + item.itemType = TimetableItemType.notSigned; }); } - void constrainBoundaries(details) { - var xOffset = matrixTimetable.row0.a+details.delta.dx; - var yOffset = matrixTimetable.row1.a+details.delta.dy; - if(xOffset>0) { - xOffset = 0; - } - - if(yOffset>0) { - yOffset = 0; - } - - var timetableHeight = getTimeTableHeight(); - var timetableWidth = getTimeTableWidth(); - var windowHeight = constraints.maxHeight; - var windowWidth = constraints.maxWidth; - - - - if(timetableHeightwindowHeight) - { - matrixPlaceTitles.setTranslationRaw(0, yOffset, 0); - if(yOffset+timetableHeight-windowHeight<0) { - yOffset = windowHeight-timetableHeight; - } - } - if(timetableWidth>windowWidth) - { - matrixTimeline.setTranslationRaw(xOffset, 0, 0); - } - setState(() { - matrixTimetable.setTranslationRaw(xOffset, yOffset, 0); - }); + Offset constrainDeltaOffset(double deltaX, double deltaY) { + var xOffset = matrixTimetable.row0.a + deltaX; + var yOffset = matrixTimetable.row1.a + deltaY; + if (xOffset > 0) { + xOffset = 0; + } + + if (yOffset > 0) { + yOffset = 0; + } + + var timetableHeight = getTimetableHeight(); + var timetableWidth = getTimetableWidth(); + var windowHeight = constraints.maxHeight; + var windowWidth = constraints.maxWidth; + + if (timetableHeight < windowHeight) { + yOffset = 0; + } + + if (timetableWidth < windowWidth) { + xOffset = 0; + } else if (xOffset + timetableWidth - windowWidth < 0) { + xOffset = windowWidth - timetableWidth; + } + + if (timetableHeight > windowHeight) { + if (yOffset + timetableHeight - windowHeight < 0) { + yOffset = windowHeight - timetableHeight; } -} + } + return Offset(xOffset, yOffset); + } + + void enforceConstraints(DragUpdateDetails details) { + var offset = constrainDeltaOffset(details.delta.dx, details.delta.dy); + setOffset(offset); + } -enum TimeTableItemType { - signed, notSigned, disabled + void setOffset(Offset offset) { + setState(() { + matrixPlaceTitles.setTranslationRaw(0, offset.dy, 0); + matrixTimeline.setTranslationRaw(offset.dx, 0, 0); + matrixTimetable.setTranslationRaw(offset.dx, offset.dy, 0); + }); + } + + void panEnded(DragEndDetails details) { + var xOffset = matrixTimetable.row0.a; + var yOffset = matrixTimetable.row1.a; + var velocity = details.velocity; + + var offset = constrainDeltaOffset( + velocity.pixelsPerSecond.dx*velocityAnimationSpeed, velocity.pixelsPerSecond.dy*velocityAnimationSpeed); + animationY = Tween(begin: yOffset, end: offset.dy).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOutQuad)); + animationX = Tween(begin: xOffset, end: offset.dx).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOutQuad)); + + _animationController.reset(); + _animationController.forward(); + } + + void panStarted(DragStartDetails details) { + _animationController.stop(); + } } +enum TimetableItemType { signed, notSigned, disabled } + class TimetablePlace { String title; int id; - TimetablePlace({ - required this.title, - required this.id - }); + TimetablePlace({required this.title, required this.id}); factory TimetablePlace.fromJson(Map json) { return TimetablePlace( @@ -297,52 +401,43 @@ class TimetablePlace { } } -class TimetableItem{ +class TimetableItem { DateTime startTime; DateTime endTime; String text; - TimeTableItemType itemType; + TimetableItemType itemType; int placeId; int id; - TimetableItem({ - required this.itemType, - required this.startTime, - required this.endTime, - required this.text, - required this.placeId, - required this.id - }); + TimetableItem( + {required this.itemType, + required this.startTime, + required this.endTime, + required this.text, + required this.placeId, + required this.id}); - static TimeTableItemType getIndicatorFromEvent(EventModel model) - { + static TimetableItemType getIndicatorFromEvent(EventModel model) { if (model.isSignedIn) { - return TimeTableItemType.signed; - } - else if(model.isEventInMyProgram==true) - { - return TimeTableItemType.signed; - } - else if(model.isGroupEvent && DataService.currentUserGroup() != null) - { - return TimeTableItemType.signed; - } - else if(model.currentParticipants != null && model.maxParticipants != null && (!DataService.isLoggedIn() || model.isFull())) - { - return TimeTableItemType.disabled; + return TimetableItemType.signed; + } else if (model.isEventInMyProgram == true) { + return TimetableItemType.signed; + } else if (model.isGroupEvent && DataService.currentUserGroup() != null) { + return TimetableItemType.signed; + } else if (model.currentParticipants != null && + model.maxParticipants != null && + (!DataService.isLoggedIn() || model.isFull())) { + return TimetableItemType.disabled; + } else if (EventModel.canSignIn(model)) { + return TimetableItemType.notSigned; } - else if (EventModel.canSignIn(model)) - { - return TimeTableItemType.notSigned; - } - return TimeTableItemType.notSigned; + return TimetableItemType.notSigned; } - static bool? getTimeTableItemTypeAsCanSignIn(TimeTableItemType type) { - if(type==TimeTableItemType.disabled) { + static bool? getTimetableItemTypeAsCanSignIn(TimetableItemType type) { + if (type == TimetableItemType.disabled) { return null; - } - else if(type==TimeTableItemType.notSigned) { + } else if (type == TimetableItemType.notSigned) { return true; } return false; @@ -358,5 +453,4 @@ class TimetableItem{ placeId: model.place!.id!, ); } - } \ No newline at end of file