From 4e61fa66df377a3f12db8e4750a83015379e8981 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Tue, 3 Sep 2024 12:31:36 -0300 Subject: [PATCH 01/14] chore: Split timeline widget files --- .../events_timeline/desktop/timeline.dart | 663 +---------------- .../desktop/timeline_view.dart | 685 ++++++++++++++++++ .../events_timeline/events_playback.dart | 1 + 3 files changed, 688 insertions(+), 661 deletions(-) create mode 100644 lib/screens/events_timeline/desktop/timeline_view.dart diff --git a/lib/screens/events_timeline/desktop/timeline.dart b/lib/screens/events_timeline/desktop/timeline.dart index 5863dae2..cc3d9c12 100644 --- a/lib/screens/events_timeline/desktop/timeline.dart +++ b/lib/screens/events_timeline/desktop/timeline.dart @@ -22,29 +22,16 @@ import 'dart:math'; import 'package:bluecherry_client/models/device.dart'; import 'package:bluecherry_client/models/event.dart'; -import 'package:bluecherry_client/providers/home_provider.dart'; import 'package:bluecherry_client/providers/settings_provider.dart'; -import 'package:bluecherry_client/screens/events_browser/events_screen.dart'; -import 'package:bluecherry_client/screens/events_timeline/desktop/timeline_card.dart'; -import 'package:bluecherry_client/screens/layouts/device_grid.dart' - show calculateCrossAxisCount; -import 'package:bluecherry_client/utils/constants.dart'; import 'package:bluecherry_client/utils/date.dart'; import 'package:bluecherry_client/utils/extensions.dart'; -import 'package:bluecherry_client/utils/methods.dart'; -import 'package:bluecherry_client/widgets/collapsable_sidebar.dart'; -import 'package:bluecherry_client/widgets/misc.dart'; -import 'package:bluecherry_client/widgets/reorderable_static_grid.dart'; -import 'package:bluecherry_client/widgets/squared_icon_button.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; import 'package:unity_video_player/unity_video_player.dart'; final timelineTimeFormat = DateFormat('hh:mm:ss a'); +final secondsInADay = const Duration(days: 1).inSeconds; /// The initial point of the timeline. enum TimelineInitialPoint { @@ -413,7 +400,7 @@ class Timeline extends ChangeNotifier { } else if (_zoom > 1.0) { final visibilityFactor = zoomController.position.viewportDimension / 6.0; final zoomedWidth = zoomController.position.viewportDimension * zoom; - final secondWidth = zoomedWidth / _secondsInADay; + final secondWidth = zoomedWidth / secondsInADay; final to = currentPosition.inSeconds * secondWidth; if (to < zoomController.position.viewportDimension) { @@ -518,649 +505,3 @@ class Timeline extends ChangeNotifier { super.dispose(); } } - -const _kDeviceNameWidth = 100.0; -const _kTimelineTileHeight = 30.0; -final _secondsInADay = const Duration(days: 1).inSeconds; - -class TimelineEventsView extends StatefulWidget { - final Timeline? timeline; - - final VoidCallback onFetch; - final Widget sidebar; - - const TimelineEventsView({ - super.key, - required this.timeline, - required this.onFetch, - required this.sidebar, - }); - - @override - State createState() => _TimelineEventsViewState(); -} - -class _TimelineEventsViewState extends State { - double? _speed; - double? _volume; - - final verticalScrollController = ScrollController(); - - bool _isCollapsed = false; - - @override - void initState() { - super.initState(); - widget.timeline?.addListener(_updateCallback); - } - - void _updateCallback() { - if (mounted) setState(() {}); - } - - Timeline get timeline => widget.timeline ?? Timeline.placeholder(); - - @override - void didUpdateWidget(covariant TimelineEventsView oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.timeline != oldWidget.timeline) { - oldWidget.timeline?.removeListener(_updateCallback); - widget.timeline?.addListener(_updateCallback); - } - } - - @override - void dispose() { - verticalScrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final loc = AppLocalizations.of(context); - final settings = context.watch(); - final home = context.watch(); - - return Column(children: [ - Expanded( - child: Row(children: [ - Expanded( - child: AspectRatio( - aspectRatio: kHorizontalAspectRatio, - child: Center( - child: StaticGrid( - padding: EdgeInsetsDirectional.zero, - reorderable: false, - crossAxisCount: calculateCrossAxisCount( - timeline.tiles.length, - ), - onReorder: (a, b) {}, - childAspectRatio: kHorizontalAspectRatio, - emptyChild: NoEventsLoaded( - isLoading: context.watch().isLoadingFor( - UnityLoadingReason.fetchingEventsHistory, - ), - ), - children: timeline.tiles.map((tile) { - return TimelineCard(tile: tile, timeline: timeline); - }).toList(), - ), - ), - ), - ), - widget.sidebar, - ]), - ), - Card( - margin: const EdgeInsetsDirectional.only( - start: 4.0, - end: 4.0, - bottom: 4.0, - ), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadiusDirectional.only( - topStart: Radius.circular(12.0), - bottomStart: Radius.circular(12.0), - bottomEnd: Radius.circular(12.0), - ), - ), - clipBehavior: Clip.antiAlias, - child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [ - Padding( - padding: const EdgeInsetsDirectional.only( - bottom: 4.0, - top: 2.0, - start: 8.0, - end: 8.0, - ), - child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - SquaredIconButton( - icon: - Icon(_isCollapsed ? Icons.expand_more : Icons.expand_less), - onPressed: () { - setState(() { - _isCollapsed = !_isCollapsed; - }); - }, - tooltip: _isCollapsed ? loc.expand : loc.collapse, - ), - Expanded( - child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ - if (timeline.pausedToBuffer.isNotEmpty) - Container( - height: 22.0, - width: 22.0, - margin: const EdgeInsetsDirectional.only(end: 8.0), - child: const CircularProgressIndicator.adaptive( - strokeWidth: 2, - ), - ), - Text( - '${(_speed ?? timeline.speed) == 1.0 ? '1' : (_speed ?? timeline.speed).toStringAsFixed(1)}' - 'x', - ), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 120.0), - child: Slider.adaptive( - value: _speed ?? timeline.speed, - min: settings.kEventsSpeed.min!, - max: settings.kEventsSpeed.max!, - onChanged: (s) => setState(() => _speed = s), - onChangeEnd: (s) { - _speed = null; - timeline.speed = s; - FocusScope.of(context).unfocus(); - }, - ), - ), - ]), - ), - const SizedBox(width: 20.0), - SquaredIconButton( - tooltip: timeline.isPlaying ? loc.pause : loc.play, - icon: PlayPauseIcon(isPlaying: timeline.isPlaying), - onPressed: () { - setState(() { - if (timeline.isPlaying) { - timeline.stop(); - } else { - timeline.play(); - } - }); - }, - ), - const SizedBox(width: 20.0), - Expanded( - child: Row(children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 120.0), - child: Slider.adaptive( - value: - _volume ?? (timeline.isMuted ? 0.0 : timeline.volume), - onChanged: (v) => setState(() => _volume = v), - onChangeEnd: (v) { - _volume = null; - timeline.volume = v; - FocusScope.of(context).unfocus(); - }, - ), - ), - Icon(() { - final volume = _volume ?? timeline.volume; - if ((_volume == null || _volume == 0.0) && - (timeline.isMuted || volume == 0.0)) { - return Icons.volume_off; - } else if (volume < 0.5) { - return Icons.volume_down; - } else { - return Icons.volume_up; - } - }()), - const Spacer(), - Expanded( - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: SizedBox( - width: kSidebarConstraints.maxWidth, - child: Center( - child: FilledButton( - onPressed: home.isLoadingFor( - UnityLoadingReason.fetchingEventsHistory, - ) - ? null - : widget.onFetch, - child: Text(loc.filter), - ), - ), - ), - ), - ), - ]), - ), - ]), - ), - Text( - '${settings.kDateFormat.value.format(timeline.currentDate)} ' - '${timelineTimeFormat.format(timeline.currentDate)}', - ), - AnimatedContainer( - duration: const Duration(milliseconds: 300), - constraints: BoxConstraints( - maxHeight: _isCollapsed ? 0.0 : _kTimelineTileHeight * 5.0, - ), - child: LayoutBuilder(builder: (context, constraints) { - if (constraints.maxHeight < _kTimelineTileHeight / 1.9) { - return const SizedBox.shrink(); - } - - final tileWidth = - (constraints.maxWidth - _kDeviceNameWidth) * timeline.zoom; - final hourWidth = tileWidth / 24; - final secondsWidth = tileWidth / _secondsInADay; - - return Stack( - fit: StackFit.passthrough, - alignment: AlignmentDirectional.bottomCenter, - children: [ - Column(mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsetsDirectional.only( - start: _kDeviceNameWidth, - ), - // a hacky workaround to make the hours to follow the zoom - // controller. - child: AnimatedBuilder( - animation: Listenable.merge([timeline.zoomController]), - builder: (context, _) { - final offset = timeline.zoomController.hasClients - ? timeline.zoomController.offset - : 0.0; - return SingleChildScrollView( - key: ValueKey(offset), - scrollDirection: Axis.horizontal, - controller: ScrollController( - initialScrollOffset: offset, - debugLabel: 'Timeline Hours Scroll Controller', - ), - child: _TimelineHours(hourWidth: hourWidth), - ); - }, - ), - ), - Flexible( - child: EnforceScrollbarScroll( - controller: verticalScrollController, - onPointerSignal: _receivedPointerSignal, - child: SingleChildScrollView( - controller: verticalScrollController, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - SizedBox( - width: _kDeviceNameWidth, - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - ...timeline.tiles.map((tile) { - return _TimelineTile.name(tile: tile); - }), - ], - ), - ), - Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTapUp: (details) { - _onMove( - details.localPosition, - constraints, - tileWidth, - ); - }, - onHorizontalDragUpdate: (details) { - _onMove( - details.localPosition, - constraints, - tileWidth, - ); - }, - child: Builder(builder: (context) { - return ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith( - physics: - const AlwaysScrollableScrollPhysics(), - ), - child: Scrollbar( - controller: timeline.zoomController, - thumbVisibility: - isMobilePlatform || kIsWeb, - child: SingleChildScrollView( - controller: timeline.zoomController, - scrollDirection: Axis.horizontal, - child: SizedBox( - width: tileWidth, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ...timeline.tiles.map((tile) { - return _TimelineTile( - key: ValueKey(tile), - tile: tile, - ); - }), - ], - ), - ), - ), - ), - ); - }), - ), - ), - ], - ), - ), - ), - ), - ]), - if (timeline.zoomController.hasClients) - Builder(builder: (context) { - final left = - (timeline.currentPosition.inSeconds * secondsWidth) - - timeline.zoomController.offset - - (/* the width of half of the triangle */ - 8 / 2); - if (left < -8.0) return const SizedBox.shrink(); - return Positioned( - key: timeline.indicatorKey, - left: _kDeviceNameWidth + left, - width: 8, - top: 12.0, - bottom: 0.0, - child: IgnorePointer( - child: Column(children: [ - ClipPath( - clipper: InvertedTriangleClipper(), - child: Container( - width: 8, - height: 4, - // color: theme.colorScheme.onSurface, - color: Colors.black, - ), - ), - Expanded( - child: Container( - // color: theme.colorScheme.onSurface, - width: 1.8, - color: Colors.black, - ), - ), - ]), - ), - ); - }), - ], - ); - }), - ), - ]), - ), - ]); - } - - // Handle mousewheel and web trackpad scroll events. - void _receivedPointerSignal(PointerSignalEvent event) { - if (widget.timeline == null || widget.timeline!.tiles.isEmpty) return; - final double scaleChange; - if (event is PointerScrollEvent) { - if (event.kind == PointerDeviceKind.trackpad) { - return; - } - // Ignore left and right mouse wheel scroll. - if (event.scrollDelta.dy == 0.0) { - return; - } - scaleChange = exp(event.scrollDelta.dy / 200); - } else if (event is PointerScaleEvent) { - scaleChange = event.scale; - } else { - return; - } - if (scaleChange < 1.0) { - timeline.zoom -= 0.8; - } else { - timeline.zoom += 0.6; - } - } - - void _onMove( - Offset localPosition, - BoxConstraints constraints, - double tileWidth, - ) { - if (!timeline.zoomController.hasClients || - localPosition.dx >= (constraints.maxWidth - _kDeviceNameWidth)) { - return; - } - final pointerPosition = - (localPosition.dx + timeline.zoomController.offset) / tileWidth; - if (pointerPosition < 0 || pointerPosition > 1) { - return; - } - - final seconds = (_secondsInADay * pointerPosition).round(); - final position = Duration(seconds: seconds); - timeline.seekTo(position); - - if (timeline.zoom > 1.0) { - // the position that the seeker will start moving - // 100. removes it from the border - final endPosition = constraints.maxWidth - _kDeviceNameWidth - 100.0; - if (localPosition.dx >= endPosition) { - timeline.scrollTo( - timeline.zoomController.offset + 25.0, - ); - } else if (localPosition.dx <= 100.0) { - timeline.scrollTo( - timeline.zoomController.offset - 25.0, - ); - } - } - } -} - -class _TimelineTile extends StatefulWidget { - final TimelineTile tile; - - const _TimelineTile({super.key, required this.tile}); - - static Widget name({required TimelineTile tile}) { - return Builder(builder: (context) { - final theme = Theme.of(context); - final border = Border( - right: BorderSide(color: theme.disabledColor.withOpacity(0.5)), - top: BorderSide(color: theme.dividerColor.withOpacity(0.5)), - ); - - return Tooltip( - message: - '${tile.device.server.name}/${tile.device.name} (${tile.events.length})', - preferBelow: false, - textStyle: theme.textTheme.labelMedium?.copyWith( - color: theme.colorScheme.onInverseSurface, - ), - verticalOffset: 12.0, - child: Container( - width: _kDeviceNameWidth, - height: _kTimelineTileHeight, - padding: const EdgeInsetsDirectional.symmetric(horizontal: 5.0), - decoration: BoxDecoration( - color: theme.dialogBackgroundColor, - border: border, - ), - alignment: AlignmentDirectional.centerStart, - child: DefaultTextStyle( - style: theme.textTheme.labelMedium!, - child: Row(children: [ - Flexible(child: Text(tile.device.name, maxLines: 1)), - Text( - ' (${tile.events.length})', - style: const TextStyle(fontSize: 10), - ), - ]), - ), - ), - ); - }); - } - - @override - State<_TimelineTile> createState() => _TimelineTileState(); -} - -class _TimelineTileState extends State<_TimelineTile> { - late final Map colors; - - @override - void initState() { - super.initState(); - colors = Map.fromIterables( - widget.tile.events.map((e) => e.event), - widget.tile.events.indexed.map((e) { - final index = e.$1; - return [ - ...Colors.primaries, - ...Colors.accents, - ][index % [...Colors.primaries, ...Colors.accents].length]; - }), - ); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final settings = context.watch(); - - final border = Border( - right: BorderSide(color: theme.disabledColor.withOpacity(0.5)), - top: BorderSide(color: theme.dividerColor.withOpacity(0.5)), - ); - - return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - ...List.generate(24, (index) { - final hour = index; - - return Expanded( - child: Container( - height: _kTimelineTileHeight, - decoration: BoxDecoration(border: border), - child: LayoutBuilder(builder: (context, constraints) { - if (!widget.tile.events - .any((event) => event.startTime.hour == hour)) { - return const SizedBox.shrink(); - } - - final secondWidth = constraints.maxWidth / 60 / 60; - - return Stack(clipBehavior: Clip.none, children: [ - for (final event in widget.tile.events - .where((event) => event.startTime.hour == hour)) - PositionedDirectional( - // the minute (in seconds) + the start second * the width of - // a second - start: ((event.startTime.minute * 60) + - event.startTime.second) * - secondWidth, - width: event.duration.inSeconds * secondWidth, - height: _kTimelineTileHeight, - child: ColoredBox( - color: settings.kShowDebugInfo.value || - settings.kShowDifferentColorsForEvents.value - ? colors[event.event] ?? theme.colorScheme.primary - : theme.colorScheme.primary, - // color: theme.colorScheme.primary, - child: settings.kShowDebugInfo.value - ? Align( - alignment: AlignmentDirectional.centerStart, - child: Text( - '${widget.tile.events.indexOf(event)}', - style: TextStyle( - color: theme.colorScheme.onPrimary, - fontSize: 10.0, - fontWeight: FontWeight.bold, - ), - ), - ) - : null, - ), - ), - ]); - }), - ), - ); - }), - ]); - } -} - -class _TimelineHours extends StatelessWidget { - /// The width of an hour - final double hourWidth; - const _TimelineHours({required this.hourWidth}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - final decWidth = hourWidth / 6; - return Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ - ...List.generate(24, (index) { - final hour = index + 1; - final shouldDisplayHour = hour < 24; - - final hourWidget = shouldDisplayHour - ? Transform.translate( - offset: Offset( - hour.toString().length * 4, - 0.0, - ), - child: Text( - '$hour', - style: theme.textTheme.labelMedium, - textAlign: TextAlign.end, - ), - ) - : const SizedBox.shrink(); - - if (decWidth > 25.0) { - return SizedBox( - width: hourWidth, - child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ - ...List.generate(5, (index) { - return SizedBox( - width: decWidth, - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: Container( - height: 6.5, - width: 2, - color: theme.colorScheme.onSurface, - ), - ), - ); - }), - const Spacer(), - hourWidget, - ]), - ); - } - - return SizedBox( - width: hourWidth, - child: hourWidget, - ); - }), - ]); - } -} diff --git a/lib/screens/events_timeline/desktop/timeline_view.dart b/lib/screens/events_timeline/desktop/timeline_view.dart new file mode 100644 index 00000000..9ba10e6b --- /dev/null +++ b/lib/screens/events_timeline/desktop/timeline_view.dart @@ -0,0 +1,685 @@ +/* + * This file is a part of Bluecherry Client (https://github.com/bluecherrydvr/unity). + * + * Copyright 2022 Bluecherry, LLC + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import 'dart:math'; + +import 'package:bluecherry_client/models/event.dart'; +import 'package:bluecherry_client/providers/home_provider.dart'; +import 'package:bluecherry_client/providers/settings_provider.dart'; +import 'package:bluecherry_client/screens/events_browser/events_screen.dart'; +import 'package:bluecherry_client/screens/events_timeline/desktop/timeline.dart'; +import 'package:bluecherry_client/screens/events_timeline/desktop/timeline_card.dart'; +import 'package:bluecherry_client/screens/layouts/device_grid.dart' + show calculateCrossAxisCount; +import 'package:bluecherry_client/utils/constants.dart'; +import 'package:bluecherry_client/utils/methods.dart'; +import 'package:bluecherry_client/widgets/collapsable_sidebar.dart'; +import 'package:bluecherry_client/widgets/misc.dart'; +import 'package:bluecherry_client/widgets/reorderable_static_grid.dart'; +import 'package:bluecherry_client/widgets/squared_icon_button.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; + +const _kDeviceNameWidth = 100.0; +const _kTimelineTileHeight = 30.0; + +class TimelineEventsView extends StatefulWidget { + final Timeline? timeline; + + final VoidCallback onFetch; + final Widget sidebar; + + const TimelineEventsView({ + super.key, + required this.timeline, + required this.onFetch, + required this.sidebar, + }); + + @override + State createState() => _TimelineEventsViewState(); +} + +class _TimelineEventsViewState extends State { + double? _speed; + double? _volume; + + final verticalScrollController = ScrollController(); + + bool _isCollapsed = false; + + @override + void initState() { + super.initState(); + widget.timeline?.addListener(_updateCallback); + } + + void _updateCallback() { + if (mounted) setState(() {}); + } + + Timeline get timeline => widget.timeline ?? Timeline.placeholder(); + + @override + void didUpdateWidget(covariant TimelineEventsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.timeline != oldWidget.timeline) { + oldWidget.timeline?.removeListener(_updateCallback); + widget.timeline?.addListener(_updateCallback); + } + } + + @override + void dispose() { + verticalScrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + final settings = context.watch(); + final home = context.watch(); + + return Column(children: [ + Expanded( + child: Row(children: [ + Expanded( + child: AspectRatio( + aspectRatio: kHorizontalAspectRatio, + child: Center( + child: StaticGrid( + padding: EdgeInsetsDirectional.zero, + reorderable: false, + crossAxisCount: calculateCrossAxisCount( + timeline.tiles.length, + ), + onReorder: (a, b) {}, + childAspectRatio: kHorizontalAspectRatio, + emptyChild: NoEventsLoaded( + isLoading: context.watch().isLoadingFor( + UnityLoadingReason.fetchingEventsHistory, + ), + ), + children: timeline.tiles.map((tile) { + return TimelineCard(tile: tile, timeline: timeline); + }).toList(), + ), + ), + ), + ), + widget.sidebar, + ]), + ), + Card( + margin: const EdgeInsetsDirectional.only( + start: 4.0, + end: 4.0, + bottom: 4.0, + ), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.only( + topStart: Radius.circular(12.0), + bottomStart: Radius.circular(12.0), + bottomEnd: Radius.circular(12.0), + ), + ), + clipBehavior: Clip.antiAlias, + child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [ + Padding( + padding: const EdgeInsetsDirectional.only( + bottom: 4.0, + top: 2.0, + start: 8.0, + end: 8.0, + ), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + SquaredIconButton( + icon: + Icon(_isCollapsed ? Icons.expand_more : Icons.expand_less), + onPressed: () { + setState(() { + _isCollapsed = !_isCollapsed; + }); + }, + tooltip: _isCollapsed ? loc.expand : loc.collapse, + ), + Expanded( + child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + if (timeline.pausedToBuffer.isNotEmpty) + Container( + height: 22.0, + width: 22.0, + margin: const EdgeInsetsDirectional.only(end: 8.0), + child: const CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ), + Text( + '${(_speed ?? timeline.speed) == 1.0 ? '1' : (_speed ?? timeline.speed).toStringAsFixed(1)}' + 'x', + ), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120.0), + child: Slider.adaptive( + value: _speed ?? timeline.speed, + min: settings.kEventsSpeed.min!, + max: settings.kEventsSpeed.max!, + onChanged: (s) => setState(() => _speed = s), + onChangeEnd: (s) { + _speed = null; + timeline.speed = s; + FocusScope.of(context).unfocus(); + }, + ), + ), + ]), + ), + const SizedBox(width: 20.0), + SquaredIconButton( + tooltip: timeline.isPlaying ? loc.pause : loc.play, + icon: PlayPauseIcon(isPlaying: timeline.isPlaying), + onPressed: () { + setState(() { + if (timeline.isPlaying) { + timeline.stop(); + } else { + timeline.play(); + } + }); + }, + ), + const SizedBox(width: 20.0), + Expanded( + child: Row(children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120.0), + child: Slider.adaptive( + value: + _volume ?? (timeline.isMuted ? 0.0 : timeline.volume), + onChanged: (v) => setState(() => _volume = v), + onChangeEnd: (v) { + _volume = null; + timeline.volume = v; + FocusScope.of(context).unfocus(); + }, + ), + ), + Icon(() { + final volume = _volume ?? timeline.volume; + if ((_volume == null || _volume == 0.0) && + (timeline.isMuted || volume == 0.0)) { + return Icons.volume_off; + } else if (volume < 0.5) { + return Icons.volume_down; + } else { + return Icons.volume_up; + } + }()), + const Spacer(), + Expanded( + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: SizedBox( + width: kSidebarConstraints.maxWidth, + child: Center( + child: FilledButton( + onPressed: home.isLoadingFor( + UnityLoadingReason.fetchingEventsHistory, + ) + ? null + : widget.onFetch, + child: Text(loc.filter), + ), + ), + ), + ), + ), + ]), + ), + ]), + ), + Text( + '${settings.kDateFormat.value.format(timeline.currentDate)} ' + '${timelineTimeFormat.format(timeline.currentDate)}', + ), + AnimatedContainer( + duration: const Duration(milliseconds: 300), + constraints: BoxConstraints( + maxHeight: _isCollapsed ? 0.0 : _kTimelineTileHeight * 5.0, + ), + child: LayoutBuilder(builder: (context, constraints) { + if (constraints.maxHeight < _kTimelineTileHeight / 1.9) { + return const SizedBox.shrink(); + } + + final tileWidth = + (constraints.maxWidth - _kDeviceNameWidth) * timeline.zoom; + final hourWidth = tileWidth / 24; + final secondsWidth = tileWidth / secondsInADay; + + return Stack( + fit: StackFit.passthrough, + alignment: AlignmentDirectional.bottomCenter, + children: [ + Column(mainAxisSize: MainAxisSize.min, children: [ + Padding( + padding: const EdgeInsetsDirectional.only( + start: _kDeviceNameWidth, + ), + // a hacky workaround to make the hours to follow the zoom + // controller. + child: AnimatedBuilder( + animation: Listenable.merge([timeline.zoomController]), + builder: (context, _) { + final offset = timeline.zoomController.hasClients + ? timeline.zoomController.offset + : 0.0; + return SingleChildScrollView( + key: ValueKey(offset), + scrollDirection: Axis.horizontal, + controller: ScrollController( + initialScrollOffset: offset, + debugLabel: 'Timeline Hours Scroll Controller', + ), + child: _TimelineHours(hourWidth: hourWidth), + ); + }, + ), + ), + Flexible( + child: EnforceScrollbarScroll( + controller: verticalScrollController, + onPointerSignal: _receivedPointerSignal, + child: SingleChildScrollView( + controller: verticalScrollController, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SizedBox( + width: _kDeviceNameWidth, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ...timeline.tiles.map((tile) { + return _TimelineTile.name(tile: tile); + }), + ], + ), + ), + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: (details) { + _onMove( + details.localPosition, + constraints, + tileWidth, + ); + }, + onHorizontalDragUpdate: (details) { + _onMove( + details.localPosition, + constraints, + tileWidth, + ); + }, + child: Builder(builder: (context) { + return ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith( + physics: + const AlwaysScrollableScrollPhysics(), + ), + child: Scrollbar( + controller: timeline.zoomController, + thumbVisibility: + isMobilePlatform || kIsWeb, + child: SingleChildScrollView( + controller: timeline.zoomController, + scrollDirection: Axis.horizontal, + child: SizedBox( + width: tileWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...timeline.tiles.map((tile) { + return _TimelineTile( + key: ValueKey(tile), + tile: tile, + ); + }), + ], + ), + ), + ), + ), + ); + }), + ), + ), + ], + ), + ), + ), + ), + ]), + if (timeline.zoomController.hasClients) + Builder(builder: (context) { + final left = + (timeline.currentPosition.inSeconds * secondsWidth) - + timeline.zoomController.offset - + (/* the width of half of the triangle */ + 8 / 2); + if (left < -8.0) return const SizedBox.shrink(); + return Positioned( + key: timeline.indicatorKey, + left: _kDeviceNameWidth + left, + width: 8, + top: 12.0, + bottom: 0.0, + child: IgnorePointer( + child: Column(children: [ + ClipPath( + clipper: InvertedTriangleClipper(), + child: Container( + width: 8, + height: 4, + // color: theme.colorScheme.onSurface, + color: Colors.black, + ), + ), + Expanded( + child: Container( + // color: theme.colorScheme.onSurface, + width: 1.8, + color: Colors.black, + ), + ), + ]), + ), + ); + }), + ], + ); + }), + ), + ]), + ), + ]); + } + + // Handle mousewheel and web trackpad scroll events. + void _receivedPointerSignal(PointerSignalEvent event) { + if (widget.timeline == null || widget.timeline!.tiles.isEmpty) return; + final double scaleChange; + if (event is PointerScrollEvent) { + if (event.kind == PointerDeviceKind.trackpad) { + return; + } + // Ignore left and right mouse wheel scroll. + if (event.scrollDelta.dy == 0.0) { + return; + } + scaleChange = exp(event.scrollDelta.dy / 200); + } else if (event is PointerScaleEvent) { + scaleChange = event.scale; + } else { + return; + } + if (scaleChange < 1.0) { + timeline.zoom -= 0.8; + } else { + timeline.zoom += 0.6; + } + } + + void _onMove( + Offset localPosition, + BoxConstraints constraints, + double tileWidth, + ) { + if (!timeline.zoomController.hasClients || + localPosition.dx >= (constraints.maxWidth - _kDeviceNameWidth)) { + return; + } + final pointerPosition = + (localPosition.dx + timeline.zoomController.offset) / tileWidth; + if (pointerPosition < 0 || pointerPosition > 1) { + return; + } + + final seconds = (secondsInADay * pointerPosition).round(); + final position = Duration(seconds: seconds); + timeline.seekTo(position); + + if (timeline.zoom > 1.0) { + // the position that the seeker will start moving + // 100. removes it from the border + final endPosition = constraints.maxWidth - _kDeviceNameWidth - 100.0; + if (localPosition.dx >= endPosition) { + timeline.scrollTo( + timeline.zoomController.offset + 25.0, + ); + } else if (localPosition.dx <= 100.0) { + timeline.scrollTo( + timeline.zoomController.offset - 25.0, + ); + } + } + } +} + +class _TimelineTile extends StatefulWidget { + final TimelineTile tile; + + const _TimelineTile({super.key, required this.tile}); + + static Widget name({required TimelineTile tile}) { + return Builder(builder: (context) { + final theme = Theme.of(context); + final border = Border( + right: BorderSide(color: theme.disabledColor.withOpacity(0.5)), + top: BorderSide(color: theme.dividerColor.withOpacity(0.5)), + ); + + return Tooltip( + message: + '${tile.device.server.name}/${tile.device.name} (${tile.events.length})', + preferBelow: false, + textStyle: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onInverseSurface, + ), + verticalOffset: 12.0, + child: Container( + width: _kDeviceNameWidth, + height: _kTimelineTileHeight, + padding: const EdgeInsetsDirectional.symmetric(horizontal: 5.0), + decoration: BoxDecoration( + color: theme.dialogBackgroundColor, + border: border, + ), + alignment: AlignmentDirectional.centerStart, + child: DefaultTextStyle( + style: theme.textTheme.labelMedium!, + child: Row(children: [ + Flexible(child: Text(tile.device.name, maxLines: 1)), + Text( + ' (${tile.events.length})', + style: const TextStyle(fontSize: 10), + ), + ]), + ), + ), + ); + }); + } + + @override + State<_TimelineTile> createState() => _TimelineTileState(); +} + +class _TimelineTileState extends State<_TimelineTile> { + late final Map colors; + + @override + void initState() { + super.initState(); + colors = Map.fromIterables( + widget.tile.events.map((e) => e.event), + widget.tile.events.indexed.map((e) { + final index = e.$1; + return [ + ...Colors.primaries, + ...Colors.accents, + ][index % [...Colors.primaries, ...Colors.accents].length]; + }), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final settings = context.watch(); + + final border = Border( + right: BorderSide(color: theme.disabledColor.withOpacity(0.5)), + top: BorderSide(color: theme.dividerColor.withOpacity(0.5)), + ); + + return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + ...List.generate(24, (index) { + final hour = index; + + return Expanded( + child: Container( + height: _kTimelineTileHeight, + decoration: BoxDecoration(border: border), + child: LayoutBuilder(builder: (context, constraints) { + if (!widget.tile.events + .any((event) => event.startTime.hour == hour)) { + return const SizedBox.shrink(); + } + + final secondWidth = constraints.maxWidth / 60 / 60; + + return Stack(clipBehavior: Clip.none, children: [ + for (final event in widget.tile.events + .where((event) => event.startTime.hour == hour)) + PositionedDirectional( + // the minute (in seconds) + the start second * the width of + // a second + start: ((event.startTime.minute * 60) + + event.startTime.second) * + secondWidth, + width: event.duration.inSeconds * secondWidth, + height: _kTimelineTileHeight, + child: ColoredBox( + color: settings.kShowDebugInfo.value || + settings.kShowDifferentColorsForEvents.value + ? colors[event.event] ?? theme.colorScheme.primary + : theme.colorScheme.primary, + // color: theme.colorScheme.primary, + child: settings.kShowDebugInfo.value + ? Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + '${widget.tile.events.indexOf(event)}', + style: TextStyle( + color: theme.colorScheme.onPrimary, + fontSize: 10.0, + fontWeight: FontWeight.bold, + ), + ), + ) + : null, + ), + ), + ]); + }), + ), + ); + }), + ]); + } +} + +class _TimelineHours extends StatelessWidget { + /// The width of an hour + final double hourWidth; + const _TimelineHours({required this.hourWidth}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final decWidth = hourWidth / 6; + return Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + ...List.generate(24, (index) { + final hour = index + 1; + final shouldDisplayHour = hour < 24; + + final hourWidget = shouldDisplayHour + ? Transform.translate( + offset: Offset( + hour.toString().length * 4, + 0.0, + ), + child: Text( + '$hour', + style: theme.textTheme.labelMedium, + textAlign: TextAlign.end, + ), + ) + : const SizedBox.shrink(); + + if (decWidth > 25.0) { + return SizedBox( + width: hourWidth, + child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + ...List.generate(5, (index) { + return SizedBox( + width: decWidth, + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Container( + height: 6.5, + width: 2, + color: theme.colorScheme.onSurface, + ), + ), + ); + }), + const Spacer(), + hourWidget, + ]), + ); + } + + return SizedBox( + width: hourWidth, + child: hourWidget, + ); + }), + ]); + } +} diff --git a/lib/screens/events_timeline/events_playback.dart b/lib/screens/events_timeline/events_playback.dart index e6eba832..89212e26 100644 --- a/lib/screens/events_timeline/events_playback.dart +++ b/lib/screens/events_timeline/events_playback.dart @@ -27,6 +27,7 @@ import 'package:bluecherry_client/providers/settings_provider.dart'; import 'package:bluecherry_client/screens/events_browser/events_screen.dart'; import 'package:bluecherry_client/screens/events_timeline/desktop/timeline.dart'; import 'package:bluecherry_client/screens/events_timeline/desktop/timeline_sidebar.dart'; +import 'package:bluecherry_client/screens/events_timeline/desktop/timeline_view.dart'; import 'package:bluecherry_client/screens/events_timeline/mobile/timeline_device_view.dart'; import 'package:bluecherry_client/utils/date.dart'; import 'package:bluecherry_client/utils/methods.dart'; From f7fc39a89d046e12418201330d85fb7e166591e5 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Tue, 3 Sep 2024 12:39:19 -0300 Subject: [PATCH 02/14] fix: Show time format according to settings in timeline --- lib/providers/settings_provider.dart | 12 ++++++++++++ lib/screens/events_timeline/desktop/timeline.dart | 2 -- .../events_timeline/desktop/timeline_view.dart | 2 +- .../events_timeline/mobile/timeline_device_view.dart | 2 +- lib/screens/settings/application.dart | 3 ++- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index a5ad099f..320da672 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -328,15 +328,27 @@ class SettingsProvider extends UnityProvider { def: Locale.fromSubtags(languageCode: Intl.getCurrentLocale()), key: 'application.language_code', ); + final kDateFormat = _SettingsOption( def: DateFormat('EEEE, dd MMMM yyyy'), key: 'application.date_format', ); + + static const availableTimeFormats = ['HH:mm', 'hh:mm a']; final kTimeFormat = _SettingsOption( def: DateFormat('hh:mm a'), key: 'application.time_format', ); + /// The extended time format adds the second to the time format. + DateFormat get extendedTimeFormat { + return switch (kTimeFormat.value.pattern!) { + 'HH:mm' => DateFormat('HH:mm:ss'), + 'hh:mm a' => DateFormat('hh:mm:ss a'), + _ => DateFormat(kTimeFormat.value.pattern), + }; + } + // TODO(bdlukaa): remove this in future releases var _hasMigratedTimezone = false; late final kConvertTimeToLocalTimezone = _SettingsOption( diff --git a/lib/screens/events_timeline/desktop/timeline.dart b/lib/screens/events_timeline/desktop/timeline.dart index cc3d9c12..213d3583 100644 --- a/lib/screens/events_timeline/desktop/timeline.dart +++ b/lib/screens/events_timeline/desktop/timeline.dart @@ -27,10 +27,8 @@ import 'package:bluecherry_client/utils/date.dart'; import 'package:bluecherry_client/utils/extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:unity_video_player/unity_video_player.dart'; -final timelineTimeFormat = DateFormat('hh:mm:ss a'); final secondsInADay = const Duration(days: 1).inSeconds; /// The initial point of the timeline. diff --git a/lib/screens/events_timeline/desktop/timeline_view.dart b/lib/screens/events_timeline/desktop/timeline_view.dart index 9ba10e6b..e81bd30c 100644 --- a/lib/screens/events_timeline/desktop/timeline_view.dart +++ b/lib/screens/events_timeline/desktop/timeline_view.dart @@ -260,7 +260,7 @@ class _TimelineEventsViewState extends State { ), Text( '${settings.kDateFormat.value.format(timeline.currentDate)} ' - '${timelineTimeFormat.format(timeline.currentDate)}', + '${settings.extendedTimeFormat.format(timeline.currentDate)}', ), AnimatedContainer( duration: const Duration(milliseconds: 300), diff --git a/lib/screens/events_timeline/mobile/timeline_device_view.dart b/lib/screens/events_timeline/mobile/timeline_device_view.dart index 9b34845b..c7a02370 100644 --- a/lib/screens/events_timeline/mobile/timeline_device_view.dart +++ b/lib/screens/events_timeline/mobile/timeline_device_view.dart @@ -395,7 +395,7 @@ class _TimelineDeviceViewState extends State { : Text( '${settings.kDateFormat.value.format(currentDate!)}' ' ' - '${timelineTimeFormat.format(currentDate!)}', + '${settings.extendedTimeFormat.format(currentDate!)}', style: theme.textTheme.labelMedium, ), ), diff --git a/lib/screens/settings/application.dart b/lib/screens/settings/application.dart index b464b9f4..163be6b1 100644 --- a/lib/screens/settings/application.dart +++ b/lib/screens/settings/application.dart @@ -298,7 +298,8 @@ class TimeFormatSection extends StatelessWidget { final settings = context.watch(); final locale = Localizations.localeOf(context).toLanguageTag(); - final patterns = ['HH:mm', 'hh:mm a'].map((e) => DateFormat(e, locale)); + final patterns = + SettingsProvider.availableTimeFormats.map((e) => DateFormat(e, locale)); final date = DateTime.utc(1969, 7, 20, 14, 18, 04); return OptionsChooserTile( title: loc.timeFormat, From e9c4ca62a71e36afbf8b5ab65d4087ba6ad93ba7 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Thu, 5 Sep 2024 09:58:13 -0300 Subject: [PATCH 03/14] feat: Allow multiple events --- .../events_timeline/desktop/timeline.dart | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/lib/screens/events_timeline/desktop/timeline.dart b/lib/screens/events_timeline/desktop/timeline.dart index 213d3583..0fbc96c8 100644 --- a/lib/screens/events_timeline/desktop/timeline.dart +++ b/lib/screens/events_timeline/desktop/timeline.dart @@ -67,10 +67,23 @@ class TimelineTile { ); } + /// Returns the current event in the tile. + /// + /// If there are more than one event that happened at the same time, the + /// continuous event will have preference. If there is no continuous event, + /// the first event will be returned. + /// + /// If there are no events playing, `null` will be returned. Event? currentEvent(DateTime currentDate) { - return events - .firstWhereOrNull((event) => event.isPlaying(currentDate)) - ?.event; + final playingEvents = events.where((event) => event.isPlaying(currentDate)); + if (playingEvents.isEmpty) return null; + if (playingEvents.length == 1) return playingEvents.first.event; + + final continuousEvent = playingEvents + .firstWhereOrNull((event) => event.event.type == EventType.continuous); + if (continuousEvent != null) return continuousEvent.event; + + return playingEvents.first.event; } } @@ -287,6 +300,7 @@ class Timeline extends ChangeNotifier { // }); // }), 'All events must have happened in the same day'); this.tiles.addAll(tiles.where((tile) { + // add the events in the same day return tile.events.any((event) { return DateUtils.isSameDay( event.startTime.toLocal(), From 994e80b2824c24f5e24af7c63ac88dafc42feccf Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Thu, 5 Sep 2024 10:03:35 -0300 Subject: [PATCH 04/14] feat: Highlight motion events in the timeline --- lib/screens/events_timeline/desktop/timeline_view.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/screens/events_timeline/desktop/timeline_view.dart b/lib/screens/events_timeline/desktop/timeline_view.dart index e81bd30c..d7857831 100644 --- a/lib/screens/events_timeline/desktop/timeline_view.dart +++ b/lib/screens/events_timeline/desktop/timeline_view.dart @@ -597,7 +597,10 @@ class _TimelineTileState extends State<_TimelineTile> { color: settings.kShowDebugInfo.value || settings.kShowDifferentColorsForEvents.value ? colors[event.event] ?? theme.colorScheme.primary - : theme.colorScheme.primary, + : switch (event.event.type) { + EventType.motion => theme.colorScheme.secondary, + _ => theme.colorScheme.primary, + }, // color: theme.colorScheme.primary, child: settings.kShowDebugInfo.value ? Align( From 56edb43605a9b06ec97c4749f1b6d7af23ee1c83 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Thu, 5 Sep 2024 10:15:03 -0300 Subject: [PATCH 05/14] feat: Do not remove events that collide --- .../events_timeline/events_playback.dart | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/lib/screens/events_timeline/events_playback.dart b/lib/screens/events_timeline/events_playback.dart index 89212e26..a499e7d0 100644 --- a/lib/screens/events_timeline/events_playback.dart +++ b/lib/screens/events_timeline/events_playback.dart @@ -97,7 +97,19 @@ class _EventsPlaybackState extends EventsScreenState { final devices = >{}; - for (final event in eventsProvider.loadedEvents!.filteredEvents) { + final events = eventsProvider.loadedEvents!.filteredEvents + ..sort((a, b) { + // Sort the events in a way that the continuous events are displayed first + // Ideally, in the Timeline, the motion events should be displayed on + // top of the continuous events. We need to sort the continuous events + // so that the continuous events don't get on top of the motion events. + final aIsContinuous = a.type == EventType.continuous; + final bIsContinuous = b.type == EventType.continuous; + if (aIsContinuous && !bIsContinuous) return -1; + if (!aIsContinuous && bIsContinuous) return 1; + return 0; + }); + for (final event in events) { if (event.isAlarm || event.mediaURL == null) continue; if (!DateUtils.isSameDay(event.published, date) || @@ -114,16 +126,19 @@ class _EventsPlaybackState extends EventsScreenState { ); devices[device] ??= []; - if (devices[device]!.any((e) { - return e.published.isInBetween(event.published, event.updated, - allowSameMoment: true) || - e.updated.isInBetween(event.published, event.updated, - allowSameMoment: true) || - event.published - .isInBetween(e.published, e.updated, allowSameMoment: true) || - event.updated - .isInBetween(e.published, e.updated, allowSameMoment: true); - })) continue; + // This ensures that events that happened at the same time are not + // displayed on the same device. + // + // if (devices[device]!.any((e) { + // return e.published.isInBetween(event.published, event.updated, + // allowSameMoment: true) || + // e.updated.isInBetween(event.published, event.updated, + // allowSameMoment: true) || + // event.published + // .isInBetween(e.published, e.updated, allowSameMoment: true) || + // event.updated + // .isInBetween(e.published, e.updated, allowSameMoment: true); + // })) continue; devices[device]!.add(event); } From 710d3357abbb522f75075df3556e1cc2563f19f2 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Thu, 5 Sep 2024 10:34:24 -0300 Subject: [PATCH 06/14] fix: All `DateFormat`s use the current locale --- lib/main.dart | 9 +++++ lib/providers/settings_provider.dart | 35 +++++++++++++------ .../desktop/timeline_sidebar.dart | 8 +++-- lib/utils/date.dart | 5 +-- 4 files changed, 43 insertions(+), 14 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index ec967be2..a2541fe0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -58,6 +58,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localized_locales/flutter_localized_locales.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:intl/intl.dart'; import 'package:path/path.dart' as path; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; @@ -82,6 +84,8 @@ Future main(List args) async { runApp(const SplashScreen()); } + await initializeDateFormatting(); + DevHttpOverrides.configureCertificates(); API.initialize(); await UnityVideoPlayerInterface.instance.initialize(); @@ -426,6 +430,11 @@ class _UnityAppState extends State return null; }, + builder: (context, child) { + Intl.defaultLocale = Localizations.localeOf(context).languageCode; + + return child!; + }, ); }), ); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 320da672..5e80b316 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -58,11 +58,11 @@ class _SettingsOption { late final String Function(T value) saveAs; late final T Function(String value) loadFrom; final ValueChanged? onChanged; - final T Function()? valueOverrider; + final T Function(T value)? valueOverrider; late T _value; - T get value => valueOverrider?.call() ?? _value; + T get value => valueOverrider?.call(_value) ?? _value; set value(T newValue) { SettingsProvider.instance.updateProperty(() { _value = newValue; @@ -329,23 +329,38 @@ class SettingsProvider extends UnityProvider { key: 'application.language_code', ); - final kDateFormat = _SettingsOption( - def: DateFormat('EEEE, dd MMMM yyyy'), + late final kDateFormat = _SettingsOption( + def: DateFormat( + 'EEEE, dd MMMM yyyy', + kLanguageCode.value.toLanguageTag(), + ), key: 'application.date_format', + valueOverrider: (value) { + return DateFormat(value.pattern, kLanguageCode.value.toLanguageTag()); + }, ); static const availableTimeFormats = ['HH:mm', 'hh:mm a']; - final kTimeFormat = _SettingsOption( - def: DateFormat('hh:mm a'), + late final kTimeFormat = _SettingsOption( + def: DateFormat('hh:mm a', kLanguageCode.value.toLanguageTag()), key: 'application.time_format', + valueOverrider: (value) { + return DateFormat(value.pattern, kLanguageCode.value.toLanguageTag()); + }, ); /// The extended time format adds the second to the time format. DateFormat get extendedTimeFormat { return switch (kTimeFormat.value.pattern!) { - 'HH:mm' => DateFormat('HH:mm:ss'), - 'hh:mm a' => DateFormat('hh:mm:ss a'), - _ => DateFormat(kTimeFormat.value.pattern), + 'HH:mm' => DateFormat('HH:mm:ss', kLanguageCode.value.toLanguageTag()), + 'hh:mm a' => DateFormat( + 'hh:mm:ss a', + kLanguageCode.value.toLanguageTag(), + ), + _ => DateFormat( + kTimeFormat.value.pattern, + kLanguageCode.value.toLanguageTag(), + ), }; } @@ -445,7 +460,7 @@ class SettingsProvider extends UnityProvider { ..zoom.softwareZoom = value; } }, - valueOverrider: isHardwareZoomSupported ? () => true : null, + valueOverrider: isHardwareZoomSupported ? (_) => true : null, ); final kEventsMatrixedZoom = _SettingsOption( def: true, diff --git a/lib/screens/events_timeline/desktop/timeline_sidebar.dart b/lib/screens/events_timeline/desktop/timeline_sidebar.dart index 0e069da8..72959530 100644 --- a/lib/screens/events_timeline/desktop/timeline_sidebar.dart +++ b/lib/screens/events_timeline/desktop/timeline_sidebar.dart @@ -27,6 +27,7 @@ import 'package:bluecherry_client/widgets/misc.dart'; import 'package:bluecherry_client/widgets/search.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; class TimelineSidebar extends StatefulWidget { @@ -90,8 +91,11 @@ class _TimelineSidebarState extends State with Searchable { loc.dateFilter, style: const TextStyle(fontWeight: FontWeight.bold), ), - subtitle: AutoSizeText( - widget.date.formatDecoratedDate(context), + trailing: AutoSizeText( + widget.date.formatDecoratedDate( + context, + DateFormat('EEE, dd MMM yyyy'), + ), maxLines: 1, ), onTap: () async { diff --git a/lib/utils/date.dart b/lib/utils/date.dart index 122dd0dc..c6a426dd 100644 --- a/lib/utils/date.dart +++ b/lib/utils/date.dart @@ -161,9 +161,10 @@ extension DateTimeExtension on DateTime? { } /// Formats the date string. - String formatDecoratedDate(BuildContext context) { + String formatDecoratedDate(BuildContext context, [DateFormat? format]) { final loc = AppLocalizations.of(context); final settings = context.read(); + format ??= settings.kDateFormat.value; var date = this; if (settings.kConvertTimeToLocalTimezone.value) date = date?.toLocal(); @@ -178,7 +179,7 @@ extension DateTimeExtension on DateTime? { )) { return loc.yesterday; } else { - return settings.kDateFormat.value.format(date); + return format!.format(date); } }(); From 364abe26281c3646440527303d99c4345681000b Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 7 Sep 2024 10:55:33 -0300 Subject: [PATCH 07/14] feat: Add event type filter for Timeline --- lib/providers/events_provider.dart | 7 ++ .../events_timeline/desktop/timeline.dart | 26 ++++- .../desktop/timeline_sidebar.dart | 96 +++++++++++++++++++ 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/lib/providers/events_provider.dart b/lib/providers/events_provider.dart index ac97a6b5..92692d2c 100644 --- a/lib/providers/events_provider.dart +++ b/lib/providers/events_provider.dart @@ -102,6 +102,13 @@ class EventsProvider extends UnityProvider { notifyListeners(); } + int _eventTypeFilter = -1; + int get eventTypeFilter => _eventTypeFilter; + set eventTypeFilter(int value) { + _eventTypeFilter = value; + notifyListeners(); + } + LoadedEvents? loadedEvents; @override diff --git a/lib/screens/events_timeline/desktop/timeline.dart b/lib/screens/events_timeline/desktop/timeline.dart index 0fbc96c8..2a839a76 100644 --- a/lib/screens/events_timeline/desktop/timeline.dart +++ b/lib/screens/events_timeline/desktop/timeline.dart @@ -22,6 +22,7 @@ import 'dart:math'; import 'package:bluecherry_client/models/device.dart'; import 'package:bluecherry_client/models/event.dart'; +import 'package:bluecherry_client/providers/events_provider.dart'; import 'package:bluecherry_client/providers/settings_provider.dart'; import 'package:bluecherry_client/utils/date.dart'; import 'package:bluecherry_client/utils/extensions.dart'; @@ -46,14 +47,15 @@ enum TimelineInitialPoint { class TimelineTile { final Device device; - final List events; + late final List _events; late final UnityVideoPlayer videoController; TimelineTile({ required this.device, - required this.events, + required List events, }) { + _events = events; videoController = UnityVideoPlayer.create( quality: UnityVideoQuality.p480, enableCache: true, @@ -62,11 +64,29 @@ class TimelineTile { matrixType: SettingsProvider.instance.kMatrixSize.value, ); videoController.setMultipleDataSource( - events.map((event) => event.videoUrl), + _events.map((event) => event.videoUrl), autoPlay: false, ); } + List get events { + final eventsProvider = EventsProvider.instance; + return _events.where((event) { + var typeFilterPasses = false; + + final eventFilter = eventsProvider.eventTypeFilter; + if (eventFilter == -1) { + typeFilterPasses = true; + } else { + typeFilterPasses = eventFilter == event.event.type.index; + } + + // TODO(bdlukaa): Add selected device filter + + return typeFilterPasses; + }).toList(); + } + /// Returns the current event in the tile. /// /// If there are more than one event that happened at the same time, the diff --git a/lib/screens/events_timeline/desktop/timeline_sidebar.dart b/lib/screens/events_timeline/desktop/timeline_sidebar.dart index 72959530..62b9e43b 100644 --- a/lib/screens/events_timeline/desktop/timeline_sidebar.dart +++ b/lib/screens/events_timeline/desktop/timeline_sidebar.dart @@ -18,6 +18,7 @@ */ import 'package:auto_size_text/auto_size_text.dart'; +import 'package:bluecherry_client/models/event.dart'; import 'package:bluecherry_client/providers/events_provider.dart'; import 'package:bluecherry_client/screens/events_browser/filter.dart'; import 'package:bluecherry_client/utils/date.dart'; @@ -45,10 +46,13 @@ class TimelineSidebar extends StatefulWidget { } class _TimelineSidebarState extends State with Searchable { + final _eventTypeFilterTileKey = GlobalKey(); + @override Widget build(BuildContext context) { final loc = AppLocalizations.of(context); final eventsProvider = context.watch(); + final theme = Theme.of(context); return Card( shape: const RoundedRectangleBorder( @@ -58,6 +62,8 @@ class _TimelineSidebarState extends State with Searchable { ), margin: const EdgeInsetsDirectional.only(end: 4.0, top: 4.0, start: 4.0), child: CollapsableSidebar( + // TODO(bdlukaa): This is not working because offline devices are being + // marked as selected. initiallyClosed: eventsProvider.selectedDevices.isNotEmpty || isEmbedded, left: false, @@ -113,9 +119,99 @@ class _TimelineSidebarState extends State with Searchable { } }, ), + ListTile( + key: _eventTypeFilterTileKey, + dense: true, + title: Text( + loc.eventType, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + trailing: AutoSizeText( + () { + final type = eventsProvider.eventTypeFilter; + // For some reason I can not use a switch here + if (type == EventType.motion.index) { + return loc.motion; + } else if (type == EventType.continuous.index) { + return loc.continuous; + } else { + return 'All'; + } + }(), + maxLines: 1, + ), + onTap: () async { + final box = _eventTypeFilterTileKey.currentContext! + .findRenderObject() as RenderBox; + + showMenu( + context: context, + position: RelativeRect.fromRect( + box.localToGlobal( + Offset.zero, + ancestor: + Navigator.of(context).context.findRenderObject(), + ) & + box.size, + Offset.zero & MediaQuery.of(context).size, + ), + constraints: BoxConstraints( + minWidth: box.size.width - 8, + maxWidth: box.size.width - 8, + ), + items: [ + PopupMenuLabel( + label: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 6.0, + ), + child: Text( + loc.eventType, + maxLines: 1, + style: theme.textTheme.labelSmall, + ), + ), + ), + const PopupMenuDivider(), + _buildMenuItem( + value: -1, + child: const Text('All'), + ), + _buildMenuItem( + value: EventType.motion.index, + child: Text(loc.motion), + ), + _buildMenuItem( + value: EventType.continuous.index, + child: Text(loc.continuous), + ), + ], + ); + }, + ), ]); }, ), ); } + + PopupMenuItem _buildMenuItem({required Widget child, required int value}) { + final eventsProvider = context.read(); + final selected = eventsProvider.eventTypeFilter == value; + + return CheckedPopupMenuItem( + value: value, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + checked: selected, + // enabled: !selected, + onTap: () { + eventsProvider.eventTypeFilter = value; + }, + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: child, + ), + ); + } } From 233e94f30fe9c49988e3f8281ebfbc8a7395a7fd Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 7 Sep 2024 11:09:13 -0300 Subject: [PATCH 08/14] feat: Add "automatically skip empty periods" option to Settings --- lib/l10n/app_en.arb | 1 + lib/l10n/app_fr.arb | 3 +- lib/l10n/app_pl.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/providers/settings_provider.dart | 13 +++- .../settings/events_and_downloads.dart | 65 +++++++++++-------- 6 files changed, 54 insertions(+), 30 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0b09b53d..b5de519d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -525,6 +525,7 @@ "beginningInitialPoint": "Beginning", "firstEventInitialPoint": "First event", "hourAgoInitialPoint": "1 hour ago", + "automaticallySkipEmptyPeriods": "Automatically skip empty periods", "@@APPLICATION": {}, "appearance": "Appearance", "theme": "Theme", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 754fec7b..e365fcac 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -501,6 +501,7 @@ "beginningInitialPoint": "Commencement", "firstEventInitialPoint": "Premier évènement", "hourAgoInitialPoint": "Il y a 1 heure", + "automaticallySkipEmptyPeriods": "Automatically skip empty periods", "@@APPLICATION": {}, "appearance": "Apparence", "theme": "Thème", @@ -644,4 +645,4 @@ "@@@Updates and Help": {}, "help": "Aide", "licenses": "Licenses" -} +} \ No newline at end of file diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index e8b50e14..b1549fec 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -525,6 +525,7 @@ "beginningInitialPoint": "Beginning", "firstEventInitialPoint": "First event", "hourAgoInitialPoint": "1 hour ago", + "automaticallySkipEmptyPeriods": "Automatically skip empty periods", "@@APPLICATION": {}, "appearance": "Appearance", "theme": "Motyw", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 6a73cf88..548b6cb6 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -525,6 +525,7 @@ "beginningInitialPoint": "Início", "firstEventInitialPoint": "Primeiro evento", "hourAgoInitialPoint": "1 hora atrás", + "automaticallySkipEmptyPeriods": "Pular períodos vazios automaticamente", "@@APPLICATION": {}, "appearance": "Visualização", "theme": "Aparência", diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 5e80b316..11134d03 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -302,20 +302,24 @@ class SettingsProvider extends UnityProvider { ); // Timeline of Events - final kShowDifferentColorsForEvents = _SettingsOption( + final kShowDifferentColorsForEvents = _SettingsOption( def: false, key: 'timeline.show_different_colors_for_events', ); - final kPauseToBuffer = _SettingsOption( + final kPauseToBuffer = _SettingsOption( def: false, key: 'timeline.pause_to_buffer', ); - final kTimelineInitialPoint = _SettingsOption( + final kTimelineInitialPoint = _SettingsOption( def: TimelineInitialPoint.beginning, key: 'timeline.initial_point', loadFrom: (value) => TimelineInitialPoint.values[int.parse(value)], saveAs: (value) => value.index.toString(), ); + final kAutomaticallySkipEmptyPeriods = _SettingsOption( + def: false, + key: 'timeline.automatically_skip_empty_periods', + ); // Application final kThemeMode = _SettingsOption( @@ -520,6 +524,7 @@ class SettingsProvider extends UnityProvider { kShowDifferentColorsForEvents.loadData(data), kPauseToBuffer.loadData(data), kTimelineInitialPoint.loadData(data), + kAutomaticallySkipEmptyPeriods.loadData(data), kThemeMode.loadData(data), kLanguageCode.loadData(data), kDateFormat.loadData(data), @@ -597,6 +602,8 @@ class SettingsProvider extends UnityProvider { kPauseToBuffer.key: kPauseToBuffer.saveAs(kPauseToBuffer.value), kTimelineInitialPoint.key: kTimelineInitialPoint.saveAs(kTimelineInitialPoint.value), + kAutomaticallySkipEmptyPeriods.key: kAutomaticallySkipEmptyPeriods + .saveAs(kAutomaticallySkipEmptyPeriods.value), kThemeMode.key: kThemeMode.saveAs(kThemeMode.value), kLanguageCode.key: kLanguageCode.saveAs(kLanguageCode.value), kDateFormat.key: kDateFormat.saveAs(kDateFormat.value), diff --git a/lib/screens/settings/events_and_downloads.dart b/lib/screens/settings/events_and_downloads.dart index d4fece9a..5b434872 100644 --- a/lib/screens/settings/events_and_downloads.dart +++ b/lib/screens/settings/events_and_downloads.dart @@ -158,6 +158,30 @@ class EventsAndDownloadsSettings extends StatelessWidget { ), const SizedBox(height: 20.0), SubHeader(loc.eventsTimeline), + OptionsChooserTile( + title: loc.initialTimelinePoint, + description: loc.initialTimelinePointDescription, + icon: Icons.flag, + value: settings.kTimelineInitialPoint.value, + values: [ + Option( + value: TimelineInitialPoint.beginning, + icon: Icons.start, + text: loc.beginningInitialPoint, + ), + Option( + value: TimelineInitialPoint.firstEvent, + icon: Icons.first_page, + text: loc.firstEventInitialPoint, + ), + Option( + value: TimelineInitialPoint.hourAgo, + icon: Icons.hourglass_bottom, + text: loc.hourAgoInitialPoint, + ), + ], + onChanged: (v) => settings.kTimelineInitialPoint.value = v, + ), CheckboxListTile.adaptive( value: settings.kShowDifferentColorsForEvents.value, onChanged: (v) { @@ -174,6 +198,21 @@ class EventsAndDownloadsSettings extends StatelessWidget { title: Text(loc.differentEventColors), subtitle: Text(loc.differentEventColorsDescription), ), + CheckboxListTile.adaptive( + value: settings.kAutomaticallySkipEmptyPeriods.value, + onChanged: (v) { + if (v != null) { + settings.kAutomaticallySkipEmptyPeriods.value = v; + } + }, + contentPadding: DesktopSettings.horizontalPadding, + secondary: CircleAvatar( + backgroundColor: Colors.transparent, + foregroundColor: theme.iconTheme.color, + child: const Icon(Icons.hourglass_empty), + ), + title: Text(loc.automaticallySkipEmptyPeriods), + ), if (settings.kShowDebugInfo.value) ...[ CheckboxListTile.adaptive( value: settings.kPauseToBuffer.value, @@ -194,32 +233,6 @@ class EventsAndDownloadsSettings extends StatelessWidget { ), ), ], - OptionsChooserTile( - title: loc.initialTimelinePoint, - description: loc.initialTimelinePointDescription, - icon: Icons.flag, - value: settings.kTimelineInitialPoint.value, - values: [ - Option( - value: TimelineInitialPoint.beginning, - icon: Icons.start, - text: loc.beginningInitialPoint, - ), - Option( - value: TimelineInitialPoint.firstEvent, - icon: Icons.first_page, - text: loc.firstEventInitialPoint, - ), - Option( - value: TimelineInitialPoint.hourAgo, - icon: Icons.hourglass_bottom, - text: loc.hourAgoInitialPoint, - ), - ], - onChanged: (v) { - settings.kTimelineInitialPoint.value = v; - }, - ), ]); } } From 5729e0c10c781449d4e4137dd7dc94cfd3c8c6fb Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 7 Sep 2024 11:27:23 -0300 Subject: [PATCH 09/14] feat: Automatically skip empty periods --- .../events_timeline/desktop/timeline.dart | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/lib/screens/events_timeline/desktop/timeline.dart b/lib/screens/events_timeline/desktop/timeline.dart index 2a839a76..36b3710a 100644 --- a/lib/screens/events_timeline/desktop/timeline.dart +++ b/lib/screens/events_timeline/desktop/timeline.dart @@ -310,6 +310,9 @@ class Timeline extends ChangeNotifier { ); } + List get allEvents => + tiles.expand((tile) => tile.events).toList(); + void add(Iterable tiles) { // assert(tiles.every((tile) { // return tile.events.every((event) { @@ -379,6 +382,22 @@ class Timeline extends ChangeNotifier { void seekBackward([Duration duration = const Duration(seconds: 15)]) => seekTo(currentPosition - duration); + void seekToEvent(TimelineEvent event) { + final tile = tiles.firstWhereOrNull((tile) => tile.events.contains(event)); + if (tile == null) { + debugPrint('Event ${event.event.id} not found in any tile'); + return; + } + final eventIndex = tile.events.indexOf(event); + tile.videoController.jumpToIndex(eventIndex); + + final position = event.position(currentDate); + tile.videoController.seekTo(position); + if (!isPlaying) tile.videoController.pause(); + + debugPrint('Seeking ${tile.device} to $position'); + } + double _volume = 1.0; bool get isMuted => volume == 0; double get volume => _volume; @@ -487,6 +506,30 @@ class Timeline extends ChangeNotifier { timer ??= Timer.periodic(period, (timer) { if (event == null) { currentPosition += period; + + if (SettingsProvider.instance.kAutomaticallySkipEmptyPeriods.value) { + final isPlaying = allEvents.any((e) => e.isPlaying(currentDate)); + if (!isPlaying) { + final nextEvent = allEvents + // It is important to sort the events by start time, so we can + // get the next event that will happen after the current date. + // If the sorting is not done, any upcoming event may be + // selected, but we want strictly the next event. + .sortedBy((e) => e.startTime) + .firstWhereOrNull((e) => e.startTime.isAfter(currentDate)); + + if (nextEvent != null) { + currentPosition = nextEvent.startTime.difference(date); + + if (nextEvent.isPlaying(currentDate)) { + seekToEvent(nextEvent); + } + } else { + stop(); + } + } + } + notifyListeners(); } From c74e62fdf0ab50453ec2453386727ec1da6cfd38 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 7 Sep 2024 11:30:25 -0300 Subject: [PATCH 10/14] fix: Expand/Collapse button icon --- lib/screens/events_timeline/desktop/timeline_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/screens/events_timeline/desktop/timeline_view.dart b/lib/screens/events_timeline/desktop/timeline_view.dart index d7857831..fcd8e06a 100644 --- a/lib/screens/events_timeline/desktop/timeline_view.dart +++ b/lib/screens/events_timeline/desktop/timeline_view.dart @@ -155,7 +155,7 @@ class _TimelineEventsViewState extends State { child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ SquaredIconButton( icon: - Icon(_isCollapsed ? Icons.expand_more : Icons.expand_less), + Icon(!_isCollapsed ? Icons.expand_more : Icons.expand_less), onPressed: () { setState(() { _isCollapsed = !_isCollapsed; From 2d0125264325b9eebaf668d300dedcbfef257b07 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 7 Sep 2024 11:47:11 -0300 Subject: [PATCH 11/14] feat: Add keyboard shortcuts tips --- lib/l10n/app_en.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/l10n/app_pt.arb | 1 + .../events_browser/events_screen_desktop.dart | 12 ++++++++++-- .../events_timeline/desktop/timeline_view.dart | 11 +++++------ 6 files changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b5de519d..7f92d5fb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -187,6 +187,7 @@ "noDevices": "No devices", "noEventsLoaded": "NO EVENTS LOADED", "noEventsLoadedTips": "• Select the cameras you want to see the events\n• Use the calendar to select a specific date or a date range \n• Use the \"Filter\" button to perform the search", + "timelineKeyboardShortcutsTips": "• Use the space bar to play/pause the timeline\n• Use the left and right arrow keys to move the timeline\n• Use the M key to mute/unmute the timeline\n• Use the mouse wheel to zoom in/out the timeline", "invalidResponse": "Invalid response received from the server", "cameraOptions": "Options", "showFullscreenCamera": "Show in fullscreen", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index e365fcac..96cb8bac 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -177,6 +177,7 @@ "noDevices": "Aucun appareil", "noEventsLoaded": "AUCUN ÉVÈNEMENT CHARGÉ", "noEventsLoadedTips": "• Sélectionnez la caméra dont vous voulez voir les evènements\n• Utilisez le calendrier pour sélectionner une date précise ou une période entre deux dates \n• Utilisez le bouton \"Filtre\" pour effectuer une recherche", + "timelineKeyboardShortcutsTips": "• Use the space bar to play/pause the timeline\n• Use the left and right arrow keys to move the timeline\n• Use the M key to mute/unmute the timeline\n • Use the mouse wheel to zoom in/out the timeline", "invalidResponse": "Réponse invalide reçu du serveur", "cameraOptions": "Options", "showFullscreenCamera": "Montrer en plein écran", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index b1549fec..767edb9e 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -187,6 +187,7 @@ "noDevices": "Brak urządzeń", "noEventsLoaded": "NIE ZAŁADOWANO ZDARZEŃ", "noEventsLoadedTips": "• Wybież kamery do podglądu zdarzeń\n• Użyj kalnedarza żeby wybrać konkretną datę lub zakres \n• Użyj przycisku \"Filtr\" aby wyszukiwać", + "timelineKeyboardShortcutsTips": "• Use the space bar to play/pause the timeline\n• Use the left and right arrow keys to move the timeline\n• Use the M key to mute/unmute the timeline\n • Use the mouse wheel to zoom in/out the timeline", "invalidResponse": "Odebrano nieprawidłową odpowiedź z serwera", "cameraOptions": "Opcje", "showFullscreenCamera": "Pokaż na pełnym ekranie", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 548b6cb6..d602f895 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -187,6 +187,7 @@ "noDevices": "Nenhum dispositivo", "noEventsLoaded": "NENHUM EVENTO CARREGADO", "noEventsLoadedTips": "• Selecione as câmeras cujas você quer ver os eventos\n• Utilize o calendário para selecionar uma data específica ou intervalo de datas \n• Use o botão \"Filtrar\" para pesquisar", + "timelineKeyboardShortcutsTips": "• Use a barra de espaço para reproduzir/pausar a linha do tempo\n• Use as setas esquerda e direita para mover a linha do tempo\n• Use a tecla M para silenciar/dessilenciar a linha do tempo\n • Use o scroll do mouse para dar zoom na linha do tempo", "invalidResponse": "Resposta inválida recebida do servidor", "cameraOptions": "Opções", "showFullscreenCamera": "Ver em tela cheia", diff --git a/lib/screens/events_browser/events_screen_desktop.dart b/lib/screens/events_browser/events_screen_desktop.dart index 3e753e2c..6d7d73f0 100644 --- a/lib/screens/events_browser/events_screen_desktop.dart +++ b/lib/screens/events_browser/events_screen_desktop.dart @@ -201,8 +201,15 @@ class _TableHeader extends SliverPersistentHeaderDelegate { class NoEventsLoaded extends StatelessWidget { final bool isLoading; + final String? text; + final List? children; - const NoEventsLoaded({super.key, this.isLoading = false}); + const NoEventsLoaded({ + super.key, + this.isLoading = false, + this.text, + this.children, + }); @override Widget build(BuildContext context) { @@ -228,9 +235,10 @@ class NoEventsLoaded extends StatelessWidget { const Divider(), const SizedBox(height: 6.0), Text( - loc.noEventsLoadedTips, + text ?? loc.noEventsLoadedTips, style: theme.textTheme.bodySmall, ), + if (children != null) ...children!, ]); } } diff --git a/lib/screens/events_timeline/desktop/timeline_view.dart b/lib/screens/events_timeline/desktop/timeline_view.dart index fcd8e06a..116a0896 100644 --- a/lib/screens/events_timeline/desktop/timeline_view.dart +++ b/lib/screens/events_timeline/desktop/timeline_view.dart @@ -119,6 +119,9 @@ class _TimelineEventsViewState extends State { isLoading: context.watch().isLoadingFor( UnityLoadingReason.fetchingEventsHistory, ), + text: '${loc.noEventsLoadedTips}' + '\n' + '\n${loc.timelineKeyboardShortcutsTips}', ), children: timeline.tiles.map((tile) { return TimelineCard(tile: tile, timeline: timeline); @@ -155,12 +158,8 @@ class _TimelineEventsViewState extends State { child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ SquaredIconButton( icon: - Icon(!_isCollapsed ? Icons.expand_more : Icons.expand_less), - onPressed: () { - setState(() { - _isCollapsed = !_isCollapsed; - }); - }, + Icon(_isCollapsed ? Icons.expand_less : Icons.expand_more), + onPressed: () => setState(() => _isCollapsed = !_isCollapsed), tooltip: _isCollapsed ? loc.expand : loc.collapse, ), Expanded( From 4c90521f9161c9f79f5852be348a6f57e2bf16b7 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 7 Sep 2024 12:10:43 -0300 Subject: [PATCH 12/14] feat: Add Previous and Next buttons --- .../events_timeline/desktop/timeline.dart | 59 ++++++++++++++----- .../desktop/timeline_view.dart | 14 +++++ .../events_timeline/events_playback.dart | 6 +- 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/lib/screens/events_timeline/desktop/timeline.dart b/lib/screens/events_timeline/desktop/timeline.dart index 36b3710a..dc699dc0 100644 --- a/lib/screens/events_timeline/desktop/timeline.dart +++ b/lib/screens/events_timeline/desktop/timeline.dart @@ -395,9 +395,45 @@ class Timeline extends ChangeNotifier { tile.videoController.seekTo(position); if (!isPlaying) tile.videoController.pause(); + updateScrollPosition(); + debugPrint('Seeking ${tile.device} to $position'); } + TimelineEvent? seekToPreviousEvent() { + final events = allEvents.sortedBy((e) => e.startTime); + + // There can be more than one event that is playing at the same time (e.g. + // continuous and motion events). We need to get the last one, the top one + // in the list. It is usually the motion event, since they are shorter. + // This way, the user will be able to traverse the events in the correct + // order. + final currentEvent = events.lastWhereOrNull( + (e) => e.isPlaying(currentDate), + ); + final previousEvent = events.lastWhereOrNull( + (e) => e.startTime.isBefore(currentEvent?.startTime ?? currentDate), + ); + + if (previousEvent != null) { + currentPosition = previousEvent.startTime.difference(date); + seekToEvent(previousEvent); + } + return previousEvent; + } + + TimelineEvent? seekToNextEvent() { + final nextEvent = allEvents + .sortedBy((e) => e.startTime) + .firstWhereOrNull((e) => e.startTime.isAfter(currentDate)); + + if (nextEvent != null) { + currentPosition = nextEvent.startTime.difference(date); + seekToEvent(nextEvent); + } + return nextEvent; + } + double _volume = 1.0; bool get isMuted => volume == 0; double get volume => _volume; @@ -445,7 +481,11 @@ class Timeline extends ChangeNotifier { double get zoom => _zoom; set zoom(double value) { value = _zoom = clampDouble(value, 1.0, maxZoom); + updateScrollPosition(); + notifyListeners(); + } + void updateScrollPosition() { if (_zoom == 1.0) { scrollTo(0.0); } else if (_zoom > 1.0) { @@ -470,7 +510,6 @@ class Timeline extends ChangeNotifier { scrollTo(to - visibilityFactor); } } - notifyListeners(); } static const maxZoom = 100.0; @@ -510,22 +549,10 @@ class Timeline extends ChangeNotifier { if (SettingsProvider.instance.kAutomaticallySkipEmptyPeriods.value) { final isPlaying = allEvents.any((e) => e.isPlaying(currentDate)); if (!isPlaying) { - final nextEvent = allEvents - // It is important to sort the events by start time, so we can - // get the next event that will happen after the current date. - // If the sorting is not done, any upcoming event may be - // selected, but we want strictly the next event. - .sortedBy((e) => e.startTime) - .firstWhereOrNull((e) => e.startTime.isAfter(currentDate)); - - if (nextEvent != null) { - currentPosition = nextEvent.startTime.difference(date); - - if (nextEvent.isPlaying(currentDate)) { - seekToEvent(nextEvent); - } - } else { + final nextEvent = seekToNextEvent(); + if (nextEvent == null) { stop(); + return; } } } diff --git a/lib/screens/events_timeline/desktop/timeline_view.dart b/lib/screens/events_timeline/desktop/timeline_view.dart index 116a0896..a7c66f90 100644 --- a/lib/screens/events_timeline/desktop/timeline_view.dart +++ b/lib/screens/events_timeline/desktop/timeline_view.dart @@ -194,6 +194,13 @@ class _TimelineEventsViewState extends State { ]), ), const SizedBox(width: 20.0), + SquaredIconButton( + tooltip: loc.previous, + icon: const Icon(Icons.skip_previous), + onPressed: () { + timeline.seekToPreviousEvent(); + }, + ), SquaredIconButton( tooltip: timeline.isPlaying ? loc.pause : loc.play, icon: PlayPauseIcon(isPlaying: timeline.isPlaying), @@ -207,6 +214,13 @@ class _TimelineEventsViewState extends State { }); }, ), + SquaredIconButton( + tooltip: loc.next, + icon: const Icon(Icons.skip_next), + onPressed: () { + timeline.seekToNextEvent(); + }, + ), const SizedBox(width: 20.0), Expanded( child: Row(children: [ diff --git a/lib/screens/events_timeline/events_playback.dart b/lib/screens/events_timeline/events_playback.dart index a499e7d0..a143a3e5 100644 --- a/lib/screens/events_timeline/events_playback.dart +++ b/lib/screens/events_timeline/events_playback.dart @@ -188,7 +188,11 @@ class _EventsPlaybackState extends EventsScreenState { return KeyEventResult.ignored; } - debugPrint(event.logicalKey.debugName); + debugPrint( + '${event.logicalKey}${event.logicalKey.debugName}' + ' - ' + '${event.physicalKey}${event.physicalKey.debugName}', + ); if (event.logicalKey == LogicalKeyboardKey.space) { if (timeline!.isPlaying) { timeline!.stop(); From 3e415ad887d4b247422d212ed44808297de07929 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 7 Sep 2024 12:40:02 -0300 Subject: [PATCH 13/14] feat: Implement new Timeline shortcuts --- .../events_timeline/desktop/timeline.dart | 6 + .../desktop/timeline_view.dart | 2 +- .../events_timeline/events_playback.dart | 134 ++++++++++++++---- 3 files changed, 116 insertions(+), 26 deletions(-) diff --git a/lib/screens/events_timeline/desktop/timeline.dart b/lib/screens/events_timeline/desktop/timeline.dart index dc699dc0..71583371 100644 --- a/lib/screens/events_timeline/desktop/timeline.dart +++ b/lib/screens/events_timeline/desktop/timeline.dart @@ -354,6 +354,7 @@ class Timeline extends ChangeNotifier { /// The current position of the timeline var currentPosition = const Duration(); + Duration get endPosition => const Duration(days: 1); DateTime get currentDate => date.add(currentPosition); @@ -382,6 +383,9 @@ class Timeline extends ChangeNotifier { void seekBackward([Duration duration = const Duration(seconds: 15)]) => seekTo(currentPosition - duration); + void stepForward() => seekTo(currentPosition + period); + void stepBackward() => seekTo(currentPosition - period); + void seekToEvent(TimelineEvent event) { final tile = tiles.firstWhereOrNull((tile) => tile.events.contains(event)); if (tile == null) { @@ -438,6 +442,8 @@ class Timeline extends ChangeNotifier { bool get isMuted => volume == 0; double get volume => _volume; set volume(double value) { + if (value < 0.0 || value > 1.0) return; + _volume = value; notifyListeners(); diff --git a/lib/screens/events_timeline/desktop/timeline_view.dart b/lib/screens/events_timeline/desktop/timeline_view.dart index a7c66f90..a8b1be9c 100644 --- a/lib/screens/events_timeline/desktop/timeline_view.dart +++ b/lib/screens/events_timeline/desktop/timeline_view.dart @@ -203,7 +203,7 @@ class _TimelineEventsViewState extends State { ), SquaredIconButton( tooltip: timeline.isPlaying ? loc.pause : loc.play, - icon: PlayPauseIcon(isPlaying: timeline.isPlaying), + icon: PlayPauseIcon(isPlaying: timeline.isPlaying, size: 24.0), onPressed: () { setState(() { if (timeline.isPlaying) { diff --git a/lib/screens/events_timeline/events_playback.dart b/lib/screens/events_timeline/events_playback.dart index a143a3e5..3070608e 100644 --- a/lib/screens/events_timeline/events_playback.dart +++ b/lib/screens/events_timeline/events_playback.dart @@ -193,32 +193,116 @@ class _EventsPlaybackState extends EventsScreenState { ' - ' '${event.physicalKey}${event.physicalKey.debugName}', ); - if (event.logicalKey == LogicalKeyboardKey.space) { - if (timeline!.isPlaying) { - timeline!.stop(); - } else { - timeline!.play(); - } - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.keyM) { - if (timeline!.isMuted) { - timeline!.volume = 1.0; - } else { - timeline!.volume = 0.0; - } - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.f5) { - fetch(); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { - timeline!.seekForward(); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { - timeline!.seekBackward(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; + switch (event.logicalKey) { + case LogicalKeyboardKey.arrowRight: + timeline!.seekForward(); + return KeyEventResult.handled; + case LogicalKeyboardKey.arrowLeft: + timeline!.seekBackward(); + return KeyEventResult.handled; + case LogicalKeyboardKey.space: + case LogicalKeyboardKey.mediaPlayPause: + if (timeline!.isPlaying) { + timeline!.stop(); + } else { + timeline!.play(); + } + return KeyEventResult.handled; + case LogicalKeyboardKey.mediaPlay: + case LogicalKeyboardKey.play: + if (!timeline!.isPlaying) { + timeline!.play(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + case LogicalKeyboardKey.mediaPause: + case LogicalKeyboardKey.pause: + case LogicalKeyboardKey.mediaStop: + if (timeline!.isPlaying) { + timeline!.stop(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + case LogicalKeyboardKey.f5: + fetch(); + return KeyEventResult.handled; + case LogicalKeyboardKey.mediaSkipForward: + case LogicalKeyboardKey.mediaTrackNext: + timeline!.seekToNextEvent(); + return KeyEventResult.handled; + case LogicalKeyboardKey.mediaSkipBackward: + case LogicalKeyboardKey.mediaTrackPrevious: + timeline!.seekToPreviousEvent(); + return KeyEventResult.handled; + case LogicalKeyboardKey.mediaStepForward: + timeline!.stepForward(); + return KeyEventResult.handled; + case LogicalKeyboardKey.mediaStepBackward: + timeline!.stepBackward(); + return KeyEventResult.handled; + case LogicalKeyboardKey.home: + case LogicalKeyboardKey.numpad0: + case LogicalKeyboardKey.digit0: + timeline!.seekTo(Duration.zero); + return KeyEventResult.handled; + case LogicalKeyboardKey.end: + timeline!.seekTo(timeline!.endPosition); + return KeyEventResult.handled; + case LogicalKeyboardKey.keyM: + if (timeline!.isMuted) { + timeline!.volume = 1.0; + } else { + timeline!.volume = 0.0; + } + return KeyEventResult.handled; + case LogicalKeyboardKey.arrowUp: + timeline!.volume += 0.1; + return KeyEventResult.handled; + case LogicalKeyboardKey.arrowDown: + timeline!.volume -= 0.1; + return KeyEventResult.handled; + + case LogicalKeyboardKey.numpad1: + case LogicalKeyboardKey.digit1: + timeline!.seekTo(timeline!.endPosition * 0.1); + return KeyEventResult.handled; + case LogicalKeyboardKey.numpad2: + case LogicalKeyboardKey.digit2: + timeline!.seekTo(timeline!.endPosition * 0.2); + return KeyEventResult.handled; + case LogicalKeyboardKey.numpad3: + case LogicalKeyboardKey.digit3: + timeline!.seekTo(timeline!.endPosition * 0.3); + return KeyEventResult.handled; + case LogicalKeyboardKey.numpad4: + case LogicalKeyboardKey.digit4: + timeline!.seekTo(timeline!.endPosition * 0.4); + return KeyEventResult.handled; + case LogicalKeyboardKey.numpad5: + case LogicalKeyboardKey.digit5: + timeline!.seekTo(timeline!.endPosition * 0.5); + return KeyEventResult.handled; + case LogicalKeyboardKey.numpad6: + case LogicalKeyboardKey.digit6: + timeline!.seekTo(timeline!.endPosition * 0.6); + return KeyEventResult.handled; + case LogicalKeyboardKey.numpad7: + case LogicalKeyboardKey.digit7: + timeline!.seekTo(timeline!.endPosition * 0.7); + return KeyEventResult.handled; + case LogicalKeyboardKey.numpad8: + case LogicalKeyboardKey.digit8: + timeline!.seekTo(timeline!.endPosition * 0.8); + return KeyEventResult.handled; + case LogicalKeyboardKey.numpad9: + case LogicalKeyboardKey.digit9: + timeline!.seekTo(timeline!.endPosition * 0.9); + return KeyEventResult.handled; + + default: + return KeyEventResult.ignored; + } }, child: LayoutBuilder(builder: (context, constraints) { final hasDrawer = Scaffold.hasDrawer(context); From 48e162eb0664095da37e20782a9ffd64985560cf Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 7 Sep 2024 12:48:38 -0300 Subject: [PATCH 14/14] feat: Event Type Filter for Events History --- lib/main.dart | 4 +- .../events_browser/event_type_filter.dart | 134 ++++++++++++++++++ lib/screens/events_browser/events_screen.dart | 24 ++-- lib/screens/events_browser/sidebar.dart | 16 +-- .../desktop/timeline_sidebar.dart | 97 +------------ 5 files changed, 154 insertions(+), 121 deletions(-) create mode 100644 lib/screens/events_browser/event_type_filter.dart diff --git a/lib/main.dart b/lib/main.dart index a2541fe0..a1695014 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -369,8 +369,8 @@ class _UnityAppState extends State if (settings.name == '/events') { final data = settings.arguments! as Map; final event = data['event'] as Event; - final upcomingEvents = - (data['upcoming'] as Iterable?) ?? []; + final upcomingEvents = (data['upcoming'] as Iterable?) ?? + List.empty(growable: true); final videoPlayer = data['videoPlayer'] as UnityVideoPlayer?; return MaterialPageRoute( diff --git a/lib/screens/events_browser/event_type_filter.dart b/lib/screens/events_browser/event_type_filter.dart new file mode 100644 index 00000000..73d3030a --- /dev/null +++ b/lib/screens/events_browser/event_type_filter.dart @@ -0,0 +1,134 @@ +/* + * This file is a part of Bluecherry Client (https://github.com/bluecherrydvr/unity). + * + * Copyright 2022 Bluecherry, LLC + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:bluecherry_client/models/event.dart'; +import 'package:bluecherry_client/providers/events_provider.dart'; +import 'package:bluecherry_client/widgets/misc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; + +class EventTypeFilterTile extends StatefulWidget { + const EventTypeFilterTile({super.key}); + + @override + State createState() => _EventTypeFilterTileState(); +} + +class _EventTypeFilterTileState extends State { + final _eventTypeFilterTileKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + final eventsProvider = context.watch(); + final theme = Theme.of(context); + + return ListTile( + key: _eventTypeFilterTileKey, + dense: true, + title: Text( + loc.eventType, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + trailing: AutoSizeText( + () { + final type = eventsProvider.eventTypeFilter; + // For some reason I can not use a switch here + if (type == EventType.motion.index) { + return loc.motion; + } else if (type == EventType.continuous.index) { + return loc.continuous; + } else { + return 'All'; + } + }(), + maxLines: 1, + ), + onTap: () async { + final box = _eventTypeFilterTileKey.currentContext!.findRenderObject() + as RenderBox; + + showMenu( + context: context, + position: RelativeRect.fromRect( + box.localToGlobal( + Offset.zero, + ancestor: Navigator.of(context).context.findRenderObject(), + ) & + box.size, + Offset.zero & MediaQuery.of(context).size, + ), + constraints: BoxConstraints( + minWidth: box.size.width - 8, + maxWidth: box.size.width - 8, + ), + items: [ + PopupMenuLabel( + label: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 6.0, + ), + child: Text( + loc.eventType, + maxLines: 1, + style: theme.textTheme.labelSmall, + ), + ), + ), + const PopupMenuDivider(), + _buildMenuItem( + value: -1, + child: const Text('All'), + ), + _buildMenuItem( + value: EventType.motion.index, + child: Text(loc.motion), + ), + _buildMenuItem( + value: EventType.continuous.index, + child: Text(loc.continuous), + ), + ], + ); + }, + ); + } + + PopupMenuItem _buildMenuItem({required Widget child, required int value}) { + final eventsProvider = context.read(); + final selected = eventsProvider.eventTypeFilter == value; + + return CheckedPopupMenuItem( + value: value, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + checked: selected, + // enabled: !selected, + onTap: () { + eventsProvider.eventTypeFilter = value; + }, + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: child, + ), + ); + } +} diff --git a/lib/screens/events_browser/events_screen.dart b/lib/screens/events_browser/events_screen.dart index fddd7662..67add2c5 100644 --- a/lib/screens/events_browser/events_screen.dart +++ b/lib/screens/events_browser/events_screen.dart @@ -87,20 +87,27 @@ class EventsScreenState extends State { final hasDrawer = Scaffold.hasDrawer(context); return LayoutBuilder(builder: (context, consts) { + final events = + (eventsProvider.loadedEvents?.filteredEvents ?? List.empty()) + .where((event) { + final typeFilter = eventsProvider.eventTypeFilter; + if (typeFilter == -1) return true; + return event.type.index == typeFilter; + }); if (hasDrawer || consts.maxWidth < kMobileBreakpoint.width) { return EventsScreenMobile( - events: eventsProvider.loadedEvents?.filteredEvents ?? [], - loadedServers: eventsProvider.loadedEvents?.events.keys ?? [], + events: events, + loadedServers: + eventsProvider.loadedEvents?.events.keys ?? List.empty(), refresh: fetch, - invalid: eventsProvider.loadedEvents?.invalidResponses ?? [], + invalid: + eventsProvider.loadedEvents?.invalidResponses ?? List.empty(), ); } return Material( child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - EventsScreenSidebar( - fetch: fetch, - ), + EventsScreenSidebar(fetch: fetch), Expanded( child: Card( margin: EdgeInsets.zero, @@ -109,10 +116,7 @@ class EventsScreenState extends State { topStart: Radius.circular(12.0), ), ), - child: EventsScreenDesktop( - events: - eventsProvider.loadedEvents?.filteredEvents ?? List.empty(), - ), + child: EventsScreenDesktop(events: events), ), ), ]), diff --git a/lib/screens/events_browser/sidebar.dart b/lib/screens/events_browser/sidebar.dart index 7db87346..26a6c35d 100644 --- a/lib/screens/events_browser/sidebar.dart +++ b/lib/screens/events_browser/sidebar.dart @@ -21,6 +21,7 @@ import 'package:bluecherry_client/providers/events_provider.dart'; import 'package:bluecherry_client/providers/home_provider.dart'; import 'package:bluecherry_client/providers/server_provider.dart'; import 'package:bluecherry_client/screens/events_browser/date_time_filter.dart'; +import 'package:bluecherry_client/screens/events_browser/event_type_filter.dart'; import 'package:bluecherry_client/screens/events_browser/filter.dart'; import 'package:bluecherry_client/screens/layouts/device_grid.dart'; import 'package:bluecherry_client/widgets/misc.dart'; @@ -76,20 +77,7 @@ class _EventsScreenSidebarState extends State ), const Divider(), const EventsDateTimeFilter(), - // const SubHeader('Minimum level', height: 24.0), - // DropdownButton( - // isExpanded: true, - // value: levelFilter, - // items: EventsMinLevelFilter.values.map((level) { - // return DropdownMenuItem( - // value: level, - // child: Text(level.name.uppercaseFirst), - // ); - // }).toList(), - // onChanged: (v) => setState( - // () => levelFilter = v ?? levelFilter, - // ), - // ), + const EventTypeFilterTile(), const SizedBox(height: 8.0), FilledButton( onPressed: isLoading ? null : widget.fetch, diff --git a/lib/screens/events_timeline/desktop/timeline_sidebar.dart b/lib/screens/events_timeline/desktop/timeline_sidebar.dart index 62b9e43b..cb4dc441 100644 --- a/lib/screens/events_timeline/desktop/timeline_sidebar.dart +++ b/lib/screens/events_timeline/desktop/timeline_sidebar.dart @@ -18,8 +18,8 @@ */ import 'package:auto_size_text/auto_size_text.dart'; -import 'package:bluecherry_client/models/event.dart'; import 'package:bluecherry_client/providers/events_provider.dart'; +import 'package:bluecherry_client/screens/events_browser/event_type_filter.dart'; import 'package:bluecherry_client/screens/events_browser/filter.dart'; import 'package:bluecherry_client/utils/date.dart'; import 'package:bluecherry_client/utils/methods.dart'; @@ -46,14 +46,10 @@ class TimelineSidebar extends StatefulWidget { } class _TimelineSidebarState extends State with Searchable { - final _eventTypeFilterTileKey = GlobalKey(); - @override Widget build(BuildContext context) { final loc = AppLocalizations.of(context); final eventsProvider = context.watch(); - final theme = Theme.of(context); - return Card( shape: const RoundedRectangleBorder( borderRadius: BorderRadiusDirectional.vertical( @@ -119,99 +115,10 @@ class _TimelineSidebarState extends State with Searchable { } }, ), - ListTile( - key: _eventTypeFilterTileKey, - dense: true, - title: Text( - loc.eventType, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - trailing: AutoSizeText( - () { - final type = eventsProvider.eventTypeFilter; - // For some reason I can not use a switch here - if (type == EventType.motion.index) { - return loc.motion; - } else if (type == EventType.continuous.index) { - return loc.continuous; - } else { - return 'All'; - } - }(), - maxLines: 1, - ), - onTap: () async { - final box = _eventTypeFilterTileKey.currentContext! - .findRenderObject() as RenderBox; - - showMenu( - context: context, - position: RelativeRect.fromRect( - box.localToGlobal( - Offset.zero, - ancestor: - Navigator.of(context).context.findRenderObject(), - ) & - box.size, - Offset.zero & MediaQuery.of(context).size, - ), - constraints: BoxConstraints( - minWidth: box.size.width - 8, - maxWidth: box.size.width - 8, - ), - items: [ - PopupMenuLabel( - label: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 6.0, - ), - child: Text( - loc.eventType, - maxLines: 1, - style: theme.textTheme.labelSmall, - ), - ), - ), - const PopupMenuDivider(), - _buildMenuItem( - value: -1, - child: const Text('All'), - ), - _buildMenuItem( - value: EventType.motion.index, - child: Text(loc.motion), - ), - _buildMenuItem( - value: EventType.continuous.index, - child: Text(loc.continuous), - ), - ], - ); - }, - ), + const EventTypeFilterTile(), ]); }, ), ); } - - PopupMenuItem _buildMenuItem({required Widget child, required int value}) { - final eventsProvider = context.read(); - final selected = eventsProvider.eventTypeFilter == value; - - return CheckedPopupMenuItem( - value: value, - padding: const EdgeInsets.symmetric(horizontal: 20.0), - checked: selected, - // enabled: !selected, - onTap: () { - eventsProvider.eventTypeFilter = value; - }, - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: child, - ), - ); - } }